From 067e03c03d48b86b647b2509bebf63a1d9af1678 Mon Sep 17 00:00:00 2001 From: Rajeshkumar S Date: Fri, 13 Nov 2026 18:42:04 +0530 Subject: [PATCH 01/15] feat(build_stream): add log query API with response-time redaction (#5803) * feat(build_stream): add log query API with response-time redaction Implements a read-only log query API for Build Stream that allows authenticated clients to retrieve stage log content with response-time redaction of sensitive data. The API enforces job ownership, path traversal protection, rate limiting, and concurrency limits. Existing job detail endpoint is extended with log metadata fields. Related: #5733 * revert(test): remove log_query FVT from this PR FVT code has not been validated in a lab environment yet and will be included in a follow-up PR. --- src/build_stream/app/api/jobs/routes.py | 13 +- src/build_stream/app/api/jobs/schemas.py | 7 + src/build_stream/app/api/logs/__init__.py | 15 + src/build_stream/app/api/logs/dependencies.py | 140 +++++++++ src/build_stream/app/api/logs/limiter.py | 116 +++++++ src/build_stream/app/api/logs/routes.py | 170 +++++++++++ src/build_stream/app/api/logs/schemas.py | 82 +++++ src/build_stream/app/api/router.py | 2 + src/build_stream/app/build_stream.ini | 16 + src/build_stream/app/common/config.py | 67 ++++- src/build_stream/app/core/logs/__init__.py | 15 + src/build_stream/app/core/logs/exceptions.py | 95 ++++++ src/build_stream/app/core/logs/redaction.py | 133 ++++++++ .../app/core/logs/redaction_patterns.py | 100 +++++++ src/build_stream/app/core/logs/services.py | 68 +++++ src/build_stream/app/infra/logs/__init__.py | 15 + .../app/infra/logs/filesystem_adapter.py | 214 +++++++++++++ .../app/orchestrator/logs/__init__.py | 15 + .../orchestrator/logs/use_cases/__init__.py | 37 +++ .../orchestrator/logs/use_cases/query_logs.py | 225 ++++++++++++++ .../logs/use_cases/stage_log_metadata.py | 70 +++++ .../nft/unit/api/logs/__init__.py | 13 + .../nft/unit/api/logs/conftest.py | 191 ++++++++++++ .../api/logs/test_get_job_log_metadata.py | 77 +++++ .../nft/unit/api/logs/test_log_limiter.py | 149 +++++++++ .../nft/unit/api/logs/test_logs_api.py | 283 ++++++++++++++++++ .../nft/unit/common/test_log_query_config.py | 88 ++++++ .../nft/unit/core/logs/__init__.py | 13 + .../nft/unit/core/logs/test_redaction.py | 198 ++++++++++++ .../nft/unit/infra/logs/__init__.py | 13 + .../infra/logs/test_filesystem_log_adapter.py | 241 +++++++++++++++ .../nft/unit/orchestrator/logs/__init__.py | 13 + .../logs/test_query_logs_use_case.py | 236 +++++++++++++++ .../logs/test_stage_log_metadata.py | 104 +++++++ 34 files changed, 3231 insertions(+), 3 deletions(-) create mode 100644 src/build_stream/app/api/logs/__init__.py create mode 100644 src/build_stream/app/api/logs/dependencies.py create mode 100644 src/build_stream/app/api/logs/limiter.py create mode 100644 src/build_stream/app/api/logs/routes.py create mode 100644 src/build_stream/app/api/logs/schemas.py create mode 100644 src/build_stream/app/core/logs/__init__.py create mode 100644 src/build_stream/app/core/logs/exceptions.py create mode 100644 src/build_stream/app/core/logs/redaction.py create mode 100644 src/build_stream/app/core/logs/redaction_patterns.py create mode 100644 src/build_stream/app/core/logs/services.py create mode 100644 src/build_stream/app/infra/logs/__init__.py create mode 100644 src/build_stream/app/infra/logs/filesystem_adapter.py create mode 100644 src/build_stream/app/orchestrator/logs/__init__.py create mode 100644 src/build_stream/app/orchestrator/logs/use_cases/__init__.py create mode 100644 src/build_stream/app/orchestrator/logs/use_cases/query_logs.py create mode 100644 src/build_stream/app/orchestrator/logs/use_cases/stage_log_metadata.py create mode 100644 test/build_stream/nft/unit/api/logs/__init__.py create mode 100644 test/build_stream/nft/unit/api/logs/conftest.py create mode 100644 test/build_stream/nft/unit/api/logs/test_get_job_log_metadata.py create mode 100644 test/build_stream/nft/unit/api/logs/test_log_limiter.py create mode 100644 test/build_stream/nft/unit/api/logs/test_logs_api.py create mode 100644 test/build_stream/nft/unit/common/test_log_query_config.py create mode 100644 test/build_stream/nft/unit/core/logs/__init__.py create mode 100644 test/build_stream/nft/unit/core/logs/test_redaction.py create mode 100644 test/build_stream/nft/unit/infra/logs/__init__.py create mode 100644 test/build_stream/nft/unit/infra/logs/test_filesystem_log_adapter.py create mode 100644 test/build_stream/nft/unit/orchestrator/logs/__init__.py create mode 100644 test/build_stream/nft/unit/orchestrator/logs/test_query_logs_use_case.py create mode 100644 test/build_stream/nft/unit/orchestrator/logs/test_stage_log_metadata.py diff --git a/src/build_stream/app/api/jobs/routes.py b/src/build_stream/app/api/jobs/routes.py index ffa9b28652..dc6f696d51 100644 --- a/src/build_stream/app/api/jobs/routes.py +++ b/src/build_stream/app/api/jobs/routes.py @@ -18,6 +18,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi.concurrency import run_in_threadpool from fastapi.responses import JSONResponse from core.cleanup.exceptions import ( @@ -43,10 +44,12 @@ ) from orchestrator.jobs.commands import CreateJobCommand from orchestrator.jobs.use_cases import CreateJobUseCase +from orchestrator.logs.use_cases import StageLogMetadataService from api.cleanup.dependencies import get_cleanup_job_use_case from api.dependencies import verify_token from api.logging_utils import create_job_log_file, log_secure_info, remove_job_logger +from api.logs.dependencies import get_stage_log_metadata_service from api.jobs.dependencies import ( get_audit_repo, get_correlation_id, @@ -238,6 +241,7 @@ async def get_job( job_repo = Depends(get_job_repo), stage_repo = Depends(get_stage_repo), audit_repo = Depends(get_audit_repo), + stage_log_metadata: StageLogMetadataService = Depends(get_stage_log_metadata_service), ) -> GetJobResponse: """Return a job if it exists for the requesting client.""" @@ -278,7 +282,10 @@ async def get_job( # Get stage breakdown — include all stages (architecture filtering # is handled by image_build_manager domain playbook directly) stages_entities = stage_repo.find_all_by_job(validated_job_id) # pylint: disable=no-member - + log_metadata = await run_in_threadpool( + lambda: [stage_log_metadata.describe(str(validated_job_id), s) for s in stages_entities] + ) + stages = [ GetStageResponse( stage_name=str(s.stage_name), @@ -290,8 +297,10 @@ async def get_job( attempt=s.attempt, log_file_path=s.log_file_path, result_detail=s.result_detail, + has_log=meta.has_log, + log_lines=meta.log_lines, ) - for s in stages_entities + for s, meta in zip(stages_entities, log_metadata) ] # Get audit events for state change timestamps diff --git a/src/build_stream/app/api/jobs/schemas.py b/src/build_stream/app/api/jobs/schemas.py index a30977a0f2..c590629e78 100644 --- a/src/build_stream/app/api/jobs/schemas.py +++ b/src/build_stream/app/api/jobs/schemas.py @@ -86,6 +86,13 @@ class GetStageResponse(BaseModel): attempt: int = Field(default=1, description="Stage attempt number (1-indexed, +1 per retry)") log_file_path: Optional[str] = Field(default=None, description="Ansible log file path on OIM host (NFS share)") result_detail: Optional[Dict[str, Any]] = Field(default=None, description="Detailed stage results (JSONB) including log_path, test_summary, artifact_dir") + has_log: bool = Field( + default=False, + description="True when the stage log can be fetched from GET /jobs/{job_id}/logs", + ) + log_lines: int = Field( + default=0, description="Number of lines in the stage log (0 when has_log is false)" + ) class CreateJobResponse(BaseModel): diff --git a/src/build_stream/app/api/logs/__init__.py b/src/build_stream/app/api/logs/__init__.py new file mode 100644 index 0000000000..5acca46030 --- /dev/null +++ b/src/build_stream/app/api/logs/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pipeline log query API (ER-BSM-003).""" diff --git a/src/build_stream/app/api/logs/dependencies.py b/src/build_stream/app/api/logs/dependencies.py new file mode 100644 index 0000000000..430f3a29ce --- /dev/null +++ b/src/build_stream/app/api/logs/dependencies.py @@ -0,0 +1,140 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-AUTH-001.6, CG-AUTH-001.9, CG-SECRETS-001.1 +# **** + +"""FastAPI dependency providers for the pipeline log query API.""" + +from functools import lru_cache +from pathlib import Path +from typing import Annotated, Iterator + +from fastapi import Depends, HTTPException, status + +from api.dependencies import get_job_repo, get_stage_repo, verify_token +from api.logging_utils import log_secure_info +from api.logs.limiter import ( + ConcurrencyLimiter, + ConcurrencyLimitExceededError, + RateLimitExceededError, + SlidingWindowRateLimiter, +) +from common.config import LogQueryConfig, load_config +from core.logs.redaction import RedactionService +from infra.logs.filesystem_adapter import FileSystemLogAdapter +from orchestrator.logs.use_cases import ( + LogQuerySettings, + LogQueryUseCase, + StageLogMetadataService, +) + + +@lru_cache(maxsize=1) +def get_log_query_config() -> LogQueryConfig: + """Load ``[log_query]`` from build_stream.ini, using defaults if unavailable.""" + try: + return load_config().log_query + except FileNotFoundError: + log_secure_info("warning", "build_stream.ini not found; using log query defaults") + except ValueError: + log_secure_info( + "error", "Invalid log query configuration; using safe defaults", exc_info=True + ) + return LogQueryConfig() + + +@lru_cache(maxsize=1) +def get_log_file_service() -> FileSystemLogAdapter: + """Provide the shared filesystem log adapter (keeps the line-count cache).""" + return FileSystemLogAdapter(Path(get_log_query_config().log_root)) + + +@lru_cache(maxsize=1) +def get_redaction_service() -> RedactionService: + """Provide the shared redaction service.""" + return RedactionService() + + +@lru_cache(maxsize=1) +def get_rate_limiter() -> SlidingWindowRateLimiter: + """Provide the per-client log query rate limiter.""" + return SlidingWindowRateLimiter(get_log_query_config().rate_limit_per_minute) + + +@lru_cache(maxsize=1) +def get_concurrency_limiter() -> ConcurrencyLimiter: + """Provide the global log query concurrency limiter.""" + return ConcurrencyLimiter(get_log_query_config().max_concurrent) + + +def get_log_query_use_case( + job_repo=Depends(get_job_repo), + stage_repo=Depends(get_stage_repo), + log_files: FileSystemLogAdapter = Depends(get_log_file_service), + redaction: RedactionService = Depends(get_redaction_service), + config: LogQueryConfig = Depends(get_log_query_config), +) -> LogQueryUseCase: + """Provide the log query use case bound to request-scoped repositories.""" + return LogQueryUseCase( + job_repo=job_repo, + stage_repo=stage_repo, + log_files=log_files, + redaction=redaction, + settings=LogQuerySettings( + default_tail_lines=config.default_tail_lines, + max_tail_lines=config.max_tail_lines, + max_response_bytes=config.max_response_bytes, + ), + ) + + +def get_stage_log_metadata_service( + log_files: FileSystemLogAdapter = Depends(get_log_file_service), +) -> StageLogMetadataService: + """Provide the stage log metadata service for GET /jobs/{job_id}.""" + return StageLogMetadataService(log_files) + + +def enforce_log_query_limits( + token_data: Annotated[dict, Depends(verify_token)], + rate_limiter: SlidingWindowRateLimiter = Depends(get_rate_limiter), + concurrency: ConcurrencyLimiter = Depends(get_concurrency_limiter), +) -> Iterator[None]: + """Apply the per-client rate limit and hold a concurrency slot for the request.""" + client_id = token_data["client_id"] + try: + rate_limiter.check(client_id) + except RateLimitExceededError as exc: + log_secure_info("warning", "Log query rate limit exceeded, status=429", client_id) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail={"error": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded"}, + headers={"Retry-After": str(exc.retry_after)}, + ) from exc + try: + with concurrency.slot(): + yield + except ConcurrencyLimitExceededError as exc: + log_secure_info("warning", "Log query concurrency limit reached, status=503", client_id) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "error": "LOG_QUERY_BUSY", + "message": "Log query capacity exhausted; retry later", + }, + headers={"Retry-After": str(exc.retry_after)}, + ) from exc diff --git a/src/build_stream/app/api/logs/limiter.py b/src/build_stream/app/api/logs/limiter.py new file mode 100644 index 0000000000..556c93fed5 --- /dev/null +++ b/src/build_stream/app/api/logs/limiter.py @@ -0,0 +1,116 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-AUTH-001.6 +# **** + +"""In-process rate and concurrency limits for the log query API (ER-BSM-003 HLD 6.2). + +BSM runs a single uvicorn process, so in-memory counters are authoritative. +Counters reset when the service restarts. +""" + +import math +import threading +import time +from collections import deque +from contextlib import contextmanager +from typing import Callable, Deque, Dict, Iterator + + +class RateLimitExceededError(Exception): + """The client exceeded its request budget for the current window.""" + + def __init__(self, retry_after: int) -> None: + """Store the number of seconds after which a retry may succeed.""" + super().__init__("Rate limit exceeded") + self.retry_after = retry_after + + +class ConcurrencyLimitExceededError(Exception): + """All concurrent log query slots are in use.""" + + def __init__(self, retry_after: int = 1) -> None: + """Store the suggested retry delay in seconds.""" + super().__init__("Log query capacity exhausted") + self.retry_after = retry_after + + +class SlidingWindowRateLimiter: # pylint: disable=too-few-public-methods + """Allow at most ``limit`` requests per key within ``window_seconds``.""" + + def __init__( + self, + limit: int, + window_seconds: float = 60.0, + clock: Callable[[], float] = time.monotonic, + max_keys: int = 1024, + ) -> None: + """Create the limiter; ``clock`` is injectable for tests.""" + self._limit = limit + self._window = window_seconds + self._clock = clock + self._max_keys = max_keys + self._events: Dict[str, Deque[float]] = {} + self._lock = threading.Lock() + + def check(self, key: str) -> None: + """Record one request for ``key``. + + Raises: + RateLimitExceededError: The key already used its budget in this window. + """ + now = self._clock() + with self._lock: + events = self._events.get(key) + if events is None: + if len(self._events) >= self._max_keys: + self._prune(now) + events = self._events.setdefault(key, deque()) + while events and now - events[0] >= self._window: + events.popleft() + if len(events) >= self._limit: + retry_after = max(1, math.ceil(self._window - (now - events[0]))) + raise RateLimitExceededError(retry_after) + events.append(now) + + def _prune(self, now: float) -> None: + """Drop keys with no requests inside the current window.""" + stale = [k for k, ev in self._events.items() if not ev or now - ev[-1] >= self._window] + for key in stale: + del self._events[key] + + +class ConcurrencyLimiter: # pylint: disable=too-few-public-methods + """Bound the number of log queries served at the same time.""" + + def __init__(self, max_concurrent: int) -> None: + """Create the limiter with ``max_concurrent`` slots.""" + self._semaphore = threading.BoundedSemaphore(max_concurrent) + + @contextmanager + def slot(self) -> Iterator[None]: + """Hold one slot for the duration of the block. + + Raises: + ConcurrencyLimitExceededError: No slot is free. + """ + if not self._semaphore.acquire(blocking=False): # pylint: disable=consider-using-with + raise ConcurrencyLimitExceededError() + try: + yield + finally: + self._semaphore.release() diff --git a/src/build_stream/app/api/logs/routes.py b/src/build_stream/app/api/logs/routes.py new file mode 100644 index 0000000000..3842fad58b --- /dev/null +++ b/src/build_stream/app/api/logs/routes.py @@ -0,0 +1,170 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-AUTH-001.2, CG-AUTH-001.9, CG-AUTH-001.6, CG-OUTPUT-001.1, CG-OUTPUT-001.2, CG-LOGGING-001.2 +# **** + +"""GET /api/v1/jobs/{job_id}/logs: redacted pipeline stage logs (ER-BSM-003 FR-2.1).""" + +import time +from dataclasses import asdict +from datetime import datetime, timezone +from typing import Annotated, NoReturn, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from api.dependencies import get_correlation_id, verify_token +from api.jobs.schemas import ErrorResponse +from api.logging_utils import log_secure_info +from api.logs.dependencies import enforce_log_query_limits, get_log_query_use_case +from api.logs.schemas import JobLogsResponse, StageLogEntry +from core.jobs.exceptions import JobNotFoundError +from core.jobs.value_objects import ClientId, CorrelationId, JobId +from core.logs.exceptions import ( + InvalidLogQueryError, + LogNotAvailableError, + LogPathViolationError, + LogUnavailableReason, + StageNotFoundForJobError, +) +from orchestrator.logs.use_cases import LogQueryCommand, LogQueryUseCase + +router = APIRouter(prefix="/jobs", tags=["Jobs"]) + +STAGE_PARAM_MAX_LENGTH = 64 + + +def _raise(status_code: int, error: str, message: str, correlation_id: str) -> NoReturn: + """Raise an HTTPException using the standard BSM error envelope.""" + raise HTTPException( + status_code=status_code, + detail=ErrorResponse( + error=error, + message=message, + correlation_id=correlation_id, + timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + ).model_dump(), + ) + + +@router.get( + "/{job_id}/logs", + response_model=JobLogsResponse, + responses={ + 200: {"description": "Redacted stage logs", "model": JobLogsResponse}, + 400: {"description": "Invalid job_id or query parameters", "model": ErrorResponse}, + 401: {"description": "Unauthorized", "model": ErrorResponse}, + 404: {"description": "Job, stage, or stage log not found", "model": ErrorResponse}, + 409: {"description": "Stored log path violation", "model": ErrorResponse}, + 422: {"description": "Stage log is not readable text", "model": ErrorResponse}, + 429: {"description": "Rate limit exceeded"}, + 500: {"description": "Internal error", "model": ErrorResponse}, + 503: {"description": "Log query capacity exhausted"}, + }, +) +def get_job_logs( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals + job_id: str, + token_data: Annotated[dict, Depends(verify_token)], + _limits: Annotated[None, Depends(enforce_log_query_limits)], + stage: Annotated[ + Optional[str], + Query(max_length=STAGE_PARAM_MAX_LENGTH, description="BSM stage name, e.g. build-image"), + ] = None, + tail_lines: Annotated[ + Optional[int], Query(description="Last N lines per stage (default from config)") + ] = None, + full: Annotated[ + bool, Query(description="Return the complete log, bounded by max_response_bytes") + ] = False, + correlation_id: CorrelationId = Depends(get_correlation_id), + use_case: LogQueryUseCase = Depends(get_log_query_use_case), +) -> JobLogsResponse: + """Return redacted Ansible execution logs for the stages of an owned job.""" + started = time.monotonic() + corr = correlation_id.value + client_id = ClientId(token_data["client_id"]) + + try: + validated_job_id = JobId(job_id) + except ValueError: + log_secure_info("warning", "Log query rejected: invalid job_id format, status=400") + _raise(status.HTTP_400_BAD_REQUEST, "INVALID_JOB_ID", "Invalid job_id format", corr) + + job_ref = str(validated_job_id) + command = LogQueryCommand( + job_id=validated_job_id, + client_id=client_id, + correlation_id=corr, + stage=stage, + tail_lines=tail_lines, + full=full, + ) + query_desc = f"stage={stage or 'all'}, tail_lines={tail_lines}, full={full}" + try: + result = use_case.execute(command) + except InvalidLogQueryError as exc: + _audit(client_id, job_ref, query_desc, 400, started) + _raise(status.HTTP_400_BAD_REQUEST, exc.error_code, exc.message, corr) + except JobNotFoundError: + _audit(client_id, job_ref, query_desc, 404, started) + _raise(status.HTTP_404_NOT_FOUND, "JOB_NOT_FOUND", "Job not found", corr) + except StageNotFoundForJobError as exc: + _audit(client_id, job_ref, query_desc, 404, started) + _raise(status.HTTP_404_NOT_FOUND, "STAGE_NOT_FOUND", exc.message, corr) + except LogNotAvailableError as exc: + if exc.reason == LogUnavailableReason.NOT_TEXT: + _audit(client_id, job_ref, query_desc, 422, started) + _raise( + status.HTTP_422_UNPROCESSABLE_CONTENT, "LOG_CONTENT_UNREADABLE", exc.message, corr + ) + _audit(client_id, job_ref, query_desc, 404, started) + _raise(status.HTTP_404_NOT_FOUND, "LOG_NOT_AVAILABLE", exc.message, corr) + except LogPathViolationError as exc: + _audit(client_id, job_ref, query_desc, 409, started) + _raise(status.HTTP_409_CONFLICT, "LOG_PATH_VIOLATION", exc.message, corr) + except Exception: # pylint: disable=broad-exception-caught + log_secure_info( + "error", f"Log query failed: correlation_id={corr}, status=500", + identifier=client_id.value, job_id=job_ref, exc_info=True, + ) + _raise( + status.HTTP_500_INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", + "An unexpected error occurred", corr, + ) + + _audit(client_id, job_ref, query_desc, 200, started) + return JobLogsResponse( + job_id=result.job_id, + correlation_id=corr, + tail_lines=result.tail_lines, + full=result.full, + stages=[StageLogEntry(**asdict(entry)) for entry in result.stages], + ) + + +def _audit( + client_id: ClientId, job_id: str, query_desc: str, status_code: int, started: float +) -> None: + """Write the NFR-4 audit record for a log query.""" + duration_ms = int((time.monotonic() - started) * 1000) + log_secure_info( + "info" if status_code == 200 else "warning", + f"Log query: job_id={job_id}, {query_desc}, status={status_code}, " + f"duration_ms={duration_ms}", + identifier=client_id.value, + job_id=job_id, + end_section=True, + ) diff --git a/src/build_stream/app/api/logs/schemas.py b/src/build_stream/app/api/logs/schemas.py new file mode 100644 index 0000000000..54f9afbea9 --- /dev/null +++ b/src/build_stream/app/api/logs/schemas.py @@ -0,0 +1,82 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-OUTPUT-001.2 +# **** + +"""Pydantic schemas for the pipeline log query API.""" + +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class StageLogEntry(BaseModel): + """Redacted log content for one stage.""" + + stage_name: str = Field(..., description="BSM stage name, e.g. build-image") + stage_state: str = Field(..., description="Stage state") + has_log: bool = Field(..., description="True when log content is returned") + log_content: Optional[str] = Field( + default=None, description="Redacted log lines joined by newlines" + ) + total_lines: int = Field(..., description="Lines in the whole stage log") + returned_lines: int = Field(..., description="Lines returned in log_content") + truncated: bool = Field(..., description="True when log_content is not the whole log") + unavailable_reason: Optional[str] = Field( + default=None, + description=( + "Why no content is returned: NOT_YET_AVAILABLE, NOT_RECORDED, " + "NOT_PUBLISHED, MISSING, or NOT_TEXT" + ), + ) + + +class JobLogsResponse(BaseModel): + """Response for GET /api/v1/jobs/{job_id}/logs.""" + + job_id: str = Field(..., description="Job identifier") + correlation_id: str = Field(..., description="Request correlation ID") + tail_lines: Optional[int] = Field( + default=None, description="Tail lines applied per stage; null when full=true" + ) + full: bool = Field(..., description="True when the complete (bounded) log was requested") + stages: List[StageLogEntry] = Field(..., description="Per-stage log entries") + + model_config = { + "json_schema_extra": { + "examples": [ + { + "job_id": "019bf590-1234-7890-abcd-ef1234567890", + "correlation_id": "0b8e3c0e-6f3c-4d8a-9a43-6f0b6a3cfa10", + "tail_lines": 50, + "full": False, + "stages": [ + { + "stage_name": "build-image", + "stage_state": "COMPLETED", + "has_log": True, + "log_content": "TASK [image_build : Build image] ****\nok: [oim]", + "total_lines": 1830, + "returned_lines": 50, + "truncated": True, + "unavailable_reason": None, + } + ], + } + ] + } + } diff --git a/src/build_stream/app/api/router.py b/src/build_stream/app/api/router.py index 5bf9fd96ef..dc313a8f8a 100644 --- a/src/build_stream/app/api/router.py +++ b/src/build_stream/app/api/router.py @@ -26,6 +26,7 @@ from api.images.routes import router as images_router from api.deploy.routes import router as deploy_router from api.upload.routes import router as upload_router +from api.logs.routes import router as logs_router api_router = APIRouter(prefix="/api/v1") @@ -39,3 +40,4 @@ api_router.include_router(images_router) api_router.include_router(deploy_router) api_router.include_router(upload_router) +api_router.include_router(logs_router) diff --git a/src/build_stream/app/build_stream.ini b/src/build_stream/app/build_stream.ini index f3dfd5b215..b6ec94eeb8 100644 --- a/src/build_stream/app/build_stream.ini +++ b/src/build_stream/app/build_stream.ini @@ -13,3 +13,19 @@ working_dir = /tmp/build_stream [file_store] base_path = ${OMNIA_DATA_PATH}/build_stream_root/artifacts +# Pipeline log query API (GET /api/v1/jobs/{job_id}/logs). +# Every key is optional; the values below are the built-in defaults. +[log_query] +# Container path of the published job logs (/). +log_root = ${OMNIA_DATA_PATH}/build_stream/log +# Lines returned when tail_lines is omitted. +default_tail_lines = 50 +# Upper bound for tail_lines (hard limit 10000). +max_tail_lines = 10000 +# Maximum log bytes returned per stage (full=true or tail); 10 MiB. +max_response_bytes = 10485760 +# Log query requests allowed per OAuth client per minute. +rate_limit_per_minute = 60 +# Log query requests served concurrently across all clients. +max_concurrent = 5 + diff --git a/src/build_stream/app/common/config.py b/src/build_stream/app/common/config.py index dd93f1c249..a3fbaf7dd2 100644 --- a/src/build_stream/app/common/config.py +++ b/src/build_stream/app/common/config.py @@ -16,7 +16,7 @@ import os import tempfile -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Optional @@ -45,12 +45,54 @@ class FileStoreConfig: base_path: str +MAX_LOG_TAIL_LINES_LIMIT = 10000 +MIN_LOG_RESPONSE_BYTES = 1024 +MAX_LOG_RESPONSE_BYTES_LIMIT = 50 * 1024 * 1024 + + +def default_log_root() -> str: + """Return the container path of the published job log directory.""" + return _expand_env("${OMNIA_DATA_PATH}/build_stream/log") + + +@dataclass +class LogQueryConfig: # pylint: disable=too-many-instance-attributes + """Pipeline log query API configuration (ER-BSM-003).""" + log_root: str = field(default_factory=default_log_root) + default_tail_lines: int = 50 + max_tail_lines: int = MAX_LOG_TAIL_LINES_LIMIT + max_response_bytes: int = 10 * 1024 * 1024 + rate_limit_per_minute: int = 60 + max_concurrent: int = 5 + + def __post_init__(self) -> None: + """Validate bounds so a bad INI value cannot disable a safety limit.""" + if not 1 <= self.max_tail_lines <= MAX_LOG_TAIL_LINES_LIMIT: + raise ValueError( + f"log_query.max_tail_lines must be between 1 and {MAX_LOG_TAIL_LINES_LIMIT}" + ) + if not 1 <= self.default_tail_lines <= self.max_tail_lines: + raise ValueError("log_query.default_tail_lines must be between 1 and max_tail_lines") + if not MIN_LOG_RESPONSE_BYTES <= self.max_response_bytes <= MAX_LOG_RESPONSE_BYTES_LIMIT: + raise ValueError( + f"log_query.max_response_bytes must be between {MIN_LOG_RESPONSE_BYTES} " + f"and {MAX_LOG_RESPONSE_BYTES_LIMIT}" + ) + if self.rate_limit_per_minute < 1: + raise ValueError("log_query.rate_limit_per_minute must be >= 1") + if self.max_concurrent < 1: + raise ValueError("log_query.max_concurrent must be >= 1") + if not os.path.isabs(self.log_root): + raise ValueError("log_query.log_root must be an absolute path") + + @dataclass class BuildStreamConfig: """BuildStream configuration.""" paths: PathsConfig artifact_store: ArtifactStoreConfig file_store: Optional[FileStoreConfig] + log_query: LogQueryConfig = field(default_factory=LogQueryConfig) def _expand_env(value: str) -> str: @@ -165,4 +207,27 @@ def load_config(config_path: Optional[str] = None) -> BuildStreamConfig: paths=paths, artifact_store=artifact_store, file_store=file_store, + log_query=_load_log_query_config(parser), + ) + + +def _load_log_query_config(parser: configparser.ConfigParser) -> LogQueryConfig: + """Parse the optional ``[log_query]`` section, falling back to defaults.""" + section = "log_query" + defaults = LogQueryConfig() + if not parser.has_section(section): + return defaults + return LogQueryConfig( + log_root=_expand_env(parser.get(section, "log_root", fallback=defaults.log_root)), + default_tail_lines=parser.getint( + section, "default_tail_lines", fallback=defaults.default_tail_lines + ), + max_tail_lines=parser.getint(section, "max_tail_lines", fallback=defaults.max_tail_lines), + max_response_bytes=parser.getint( + section, "max_response_bytes", fallback=defaults.max_response_bytes + ), + rate_limit_per_minute=parser.getint( + section, "rate_limit_per_minute", fallback=defaults.rate_limit_per_minute + ), + max_concurrent=parser.getint(section, "max_concurrent", fallback=defaults.max_concurrent), ) diff --git a/src/build_stream/app/core/logs/__init__.py b/src/build_stream/app/core/logs/__init__.py new file mode 100644 index 0000000000..a4fca4403c --- /dev/null +++ b/src/build_stream/app/core/logs/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pipeline log observability domain (ER-BSM-003).""" diff --git a/src/build_stream/app/core/logs/exceptions.py b/src/build_stream/app/core/logs/exceptions.py new file mode 100644 index 0000000000..e8ed410bb7 --- /dev/null +++ b/src/build_stream/app/core/logs/exceptions.py @@ -0,0 +1,95 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-OUTPUT-001.2 +# **** + +"""Domain exceptions for pipeline log queries. + +Messages are client-safe: they never contain file-system paths or log content. +""" + +from enum import Enum + + +class LogUnavailableReason(str, Enum): + """Why a stage log cannot be served.""" + + NOT_YET_AVAILABLE = "NOT_YET_AVAILABLE" + NOT_RECORDED = "NOT_RECORDED" + NOT_PUBLISHED = "NOT_PUBLISHED" + MISSING = "MISSING" + NOT_TEXT = "NOT_TEXT" + + +UNAVAILABLE_MESSAGES = { + LogUnavailableReason.NOT_YET_AVAILABLE: "Stage log not yet available", + LogUnavailableReason.NOT_RECORDED: "Log file not available for this stage", + LogUnavailableReason.NOT_PUBLISHED: "Log file not available for this stage", + LogUnavailableReason.MISSING: "Log file not available for this stage", + LogUnavailableReason.NOT_TEXT: "Stage log is not readable text", +} + + +class LogQueryError(Exception): + """Base class for log query errors.""" + + def __init__(self, message: str) -> None: + """Store a client-safe message.""" + super().__init__(message) + self.message = message + + +class InvalidLogQueryError(LogQueryError): + """Query parameters are invalid.""" + + def __init__(self, error_code: str, message: str) -> None: + """Store the stable error code with the message.""" + super().__init__(message) + self.error_code = error_code + + +class StageNotFoundForJobError(LogQueryError): + """The requested stage does not exist for the job.""" + + def __init__(self) -> None: + """Use the contract message from ER-BSM-003 FR-2.1.""" + super().__init__("Stage not found for job") + + +class LogNotAvailableError(LogQueryError): + """The stage log cannot be served (not produced, not published, or removed).""" + + def __init__(self, reason: LogUnavailableReason) -> None: + """Map the reason to its client-safe message.""" + super().__init__(UNAVAILABLE_MESSAGES[reason]) + self.reason = reason + + +class LogContentError(LogNotAvailableError): + """The stage log contains binary content and is not served.""" + + def __init__(self) -> None: + """Report the log as non-text.""" + super().__init__(LogUnavailableReason.NOT_TEXT) + + +class LogPathViolationError(LogQueryError): + """A stored log path resolves outside the configured log root.""" + + def __init__(self) -> None: + """Use a generic message that does not echo the offending path.""" + super().__init__("Log path violation") diff --git a/src/build_stream/app/core/logs/redaction.py b/src/build_stream/app/core/logs/redaction.py new file mode 100644 index 0000000000..2b1aa9af3d --- /dev/null +++ b/src/build_stream/app/core/logs/redaction.py @@ -0,0 +1,133 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-LOGGING-001.2, CG-OUTPUT-001.3 +# **** + +"""Response-time credential redaction (ER-BSM-003 FR-3.2).""" + +import re +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional, Sequence + +from core.logs.redaction_patterns import ( + IDENTIFIER_RULES, + PRIVATE_KEY_BEGIN, + PRIVATE_KEY_END, + REDACTED, + REDACTION_SUPPRESSED_LINE, + SECRET_RULES, + RedactionRule, +) + + +class RedactionTier(str, Enum): + """Which pattern tiers to apply.""" + + SECRETS_ONLY = "secrets_only" + FULL = "full" + + +@dataclass(frozen=True) +class RedactionResult: + """Outcome of redacting a window of lines. + + Attributes: + lines: Redacted lines. + redacted_lines: Number of lines whose content changed. + suppressed_lines: Number of lines withheld because redaction failed. + """ + + lines: List[str] + redacted_lines: int + suppressed_lines: int + + +class RedactionService: + """Applies the tiered pattern registry to log lines. + + Multi-line PEM private keys are tracked across lines. When a window starts + inside a key block (an END marker appears before any BEGIN marker), every + line up to and including that END marker is redacted. Redaction fails + closed: a line that cannot be processed is replaced, never passed through. + """ + + def __init__( + self, + secret_rules: Optional[Sequence[RedactionRule]] = None, + identifier_rules: Optional[Sequence[RedactionRule]] = None, + ) -> None: + """Create the service, optionally with custom rule sets (used by tests).""" + self._secret_rules = list(SECRET_RULES if secret_rules is None else secret_rules) + self._identifier_rules = list( + IDENTIFIER_RULES if identifier_rules is None else identifier_rules + ) + + def redact(self, text: str, tier: RedactionTier = RedactionTier.SECRETS_ONLY) -> str: + """Redact a block of text and return it.""" + return "\n".join(self.redact_lines(text.split("\n"), tier).lines) + + def redact_lines( + self, + lines: Sequence[str], + tier: RedactionTier = RedactionTier.SECRETS_ONLY, + ) -> RedactionResult: + """Redact each line and report how many lines changed or were withheld.""" + rules = self._secret_rules + if tier == RedactionTier.FULL: + rules = rules + self._identifier_rules + leading_block_end = _leading_key_block_end(lines) + in_key_block = False + output: List[str] = [] + redacted = 0 + suppressed = 0 + for index, line in enumerate(lines): + try: + if index <= leading_block_end or in_key_block: + if in_key_block and PRIVATE_KEY_END.search(line): + in_key_block = False + new_line = REDACTED + else: + begin = PRIVATE_KEY_BEGIN.search(line) + if begin and not PRIVATE_KEY_END.search(line, begin.end()): + in_key_block = True + new_line = _apply_rules(line, rules) + except (re.error, RecursionError, TypeError): + output.append(REDACTION_SUPPRESSED_LINE) + suppressed += 1 + continue + if new_line != line: + redacted += 1 + output.append(new_line) + return RedactionResult(lines=output, redacted_lines=redacted, suppressed_lines=suppressed) + + +def _apply_rules(line: str, rules: Sequence[RedactionRule]) -> str: + """Apply every rule to a single line.""" + for pattern, replacement in rules: + line = pattern.sub(replacement, line) + return line + + +def _leading_key_block_end(lines: Sequence[str]) -> int: + """Return the index of an END key marker seen before any BEGIN marker, else -1.""" + for index, line in enumerate(lines): + if PRIVATE_KEY_BEGIN.search(line): + return -1 + if PRIVATE_KEY_END.search(line): + return index + return -1 diff --git a/src/build_stream/app/core/logs/redaction_patterns.py b/src/build_stream/app/core/logs/redaction_patterns.py new file mode 100644 index 0000000000..3a3b0f9297 --- /dev/null +++ b/src/build_stream/app/core/logs/redaction_patterns.py @@ -0,0 +1,100 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-LOGGING-001.2, CG-SECRETS-001.1 +# **** + +"""Tiered credential redaction pattern registry (ER-BSM-003 HLD 5.3.4). + +Tier 1 (secrets) is applied to every log served by the API. Tier 2 (IP and +e-mail identifiers) is applied only when full redaction is requested; Tier 3 +operational data (host IPs, stage names, task output) is never redacted from +execution logs. + +Every pattern uses bounded quantifiers so a single long line cannot cause +catastrophic backtracking. Replacements are idempotent: re-applying a pattern +to already-redacted text leaves it unchanged. +""" + +import re +from typing import Callable, List, Pattern, Tuple, Union + +REDACTED = "***REDACTED***" +REDACTION_SUPPRESSED_LINE = "[REDACTION ERROR \u2014 line suppressed]" +REDACTED_IP = "" +REDACTED_EMAIL = "" + +Replacement = Union[str, Callable[[re.Match], str]] +RedactionRule = Tuple[Pattern[str], Replacement] + +PRIVATE_KEY_BEGIN = re.compile(r"-----BEGIN (?:[A-Z0-9]{1,16} ){0,4}PRIVATE KEY-----") +PRIVATE_KEY_END = re.compile(r"-----END (?:[A-Z0-9]{1,16} ){0,4}PRIVATE KEY-----") + +_SECRET_KEY_WORDS = ( + r"(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|credential|private[_-]?key)" +) + +_KEY_VALUE = re.compile( + r"(?P(?\"[^\"\n]{0,4096}\"|'[^'\n]{0,4096}'|[^\s,;\"'}\])]{1,4096})", + re.IGNORECASE, +) + + +def _redact_key_value(match: re.Match) -> str: + """Replace the value of a secret-bearing key, keeping any surrounding quotes.""" + value = match.group("value") + if value[0] in "\"'": + return f"{match.group('prefix')}{value[0]}{REDACTED}{value[0]}" + return f"{match.group('prefix')}{REDACTED}" + + +SECRET_RULES: List[RedactionRule] = [ + ( + re.compile( + r"-----BEGIN (?:[A-Z0-9]{1,16} ){0,4}PRIVATE KEY-----.*?" + r"(?:-----END (?:[A-Z0-9]{1,16} ){0,4}PRIVATE KEY-----|$)" + ), + REDACTED, + ), + ( + re.compile( + r"\b(?:glpat|glprt|glrt|gldt|glptt|glcbt|glimt|glagent|gloas|glsoat|glffct|glft)" + r"-[A-Za-z0-9_-]{8,256}" + ), + REDACTED, + ), + ( + re.compile(r"\beyJ[A-Za-z0-9_-]{5,4096}\.[A-Za-z0-9_-]{5,4096}\.[A-Za-z0-9_-]{5,4096}"), + REDACTED, + ), + (re.compile(r"(?i)\b(bearer\s{1,5})[A-Za-z0-9._~+/=-]{1,4096}"), r"\1" + REDACTED), + ( + re.compile(r"(?i)\b([a-z][a-z0-9+.-]{1,20}://[^\s:/@]{1,128}:)[^\s@/]{1,256}@"), + r"\1" + REDACTED + "@", + ), + (_KEY_VALUE, _redact_key_value), +] + +IDENTIFIER_RULES: List[RedactionRule] = [ + (re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), REDACTED_IP), + (re.compile(r"\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b"), REDACTED_IP), + ( + re.compile(r"\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,24}\b"), + REDACTED_EMAIL, + ), +] diff --git a/src/build_stream/app/core/logs/services.py b/src/build_stream/app/core/logs/services.py new file mode 100644 index 0000000000..7852298711 --- /dev/null +++ b/src/build_stream/app/core/logs/services.py @@ -0,0 +1,68 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-INPUT-001.5 +# **** + +"""Port for reading published stage logs.""" + +from dataclasses import dataclass +from pathlib import Path +from typing import List, Protocol + + +@dataclass(frozen=True) +class LogReadResult: + """A window of log lines read from one stage log. + + Attributes: + lines: Decoded lines (without trailing newline characters). + total_lines: Number of lines in the whole file at read time. + truncated: True when ``lines`` is not the entire log. + """ + + lines: List[str] + total_lines: int + truncated: bool + + +class LogFileService(Protocol): + """Resolves and reads stage log files below the configured log root.""" + + def resolve(self, stored_path: str, job_id: str) -> Path: + """Translate a stored host log path to a readable path under the log root. + + Raises: + LogNotAvailableError: The log is not published or no longer exists. + LogPathViolationError: The stored path escapes the log root. + """ + + def read_tail(self, path: Path, lines: int, max_bytes: int) -> LogReadResult: + """Return the last ``lines`` lines, bounded by ``max_bytes``. + + Raises: + LogContentError: The file contains binary content. + """ + + def read_full(self, path: Path, max_bytes: int) -> LogReadResult: + """Return the whole log, keeping only the last ``max_bytes`` bytes. + + Raises: + LogContentError: The file contains binary content. + """ + + def count_lines(self, path: Path) -> int: + """Return the number of lines in the log.""" diff --git a/src/build_stream/app/infra/logs/__init__.py b/src/build_stream/app/infra/logs/__init__.py new file mode 100644 index 0000000000..b1f2a962b6 --- /dev/null +++ b/src/build_stream/app/infra/logs/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Infrastructure adapters for pipeline log access.""" diff --git a/src/build_stream/app/infra/logs/filesystem_adapter.py b/src/build_stream/app/infra/logs/filesystem_adapter.py new file mode 100644 index 0000000000..f51a8f3c4e --- /dev/null +++ b/src/build_stream/app/infra/logs/filesystem_adapter.py @@ -0,0 +1,214 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-INPUT-001.5, CG-OUTPUT-001.2 +# **** + +"""Filesystem adapter serving published stage logs (ER-BSM-003 HLD 5.3). + +The playbook watcher publishes each stage log to +``/build_stream/log//`` and the result +poller stores that *host* path in ``job_stages.log_file_path``. Inside the BSM +container the same directory is mounted under a different prefix, so stored +paths are never opened directly: only the ``/`` suffix after the +``build_stream/log`` segment is kept and re-rooted under the configured log +root. The result is canonicalised (symlinks resolved) and must stay inside the +root. +""" + +import os +import re +import threading +from collections import OrderedDict +from pathlib import Path, PurePosixPath +from typing import BinaryIO, List, Optional, Tuple + +from core.logs.exceptions import ( + LogContentError, + LogNotAvailableError, + LogPathViolationError, + LogUnavailableReason, +) +from core.logs.services import LogReadResult + +PUBLISHED_LOG_SEGMENTS = ("build_stream", "log") +UNPUBLISHED_LOG_PREFIX = PurePosixPath("/var/log/omnia") +SAFE_LOG_FILE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") +READ_CHUNK_BYTES = 64 * 1024 + + +class FileSystemLogAdapter: + """Reads stage logs below a single configured log root.""" + + def __init__(self, log_root: Path, line_cache_size: int = 256) -> None: + """Create the adapter. + + Args: + log_root: Container path of the published job log directory. + line_cache_size: Number of (path, size, mtime) line counts to cache. + """ + self._root = Path(log_root).resolve() + self._line_cache: "OrderedDict[Tuple[str, int, int], int]" = OrderedDict() + self._line_cache_size = line_cache_size + self._lock = threading.Lock() + + @property + def log_root(self) -> Path: + """Return the canonical log root.""" + return self._root + + def resolve(self, stored_path: str, job_id: str) -> Path: + """Translate a stored host log path to a canonical path under the log root.""" + if not stored_path: + raise LogNotAvailableError(LogUnavailableReason.NOT_RECORDED) + if "\x00" in stored_path: + raise LogPathViolationError() + pure = PurePosixPath(stored_path) + if not pure.is_absolute() or ".." in pure.parts: + raise LogPathViolationError() + + marker = _last_marker_index(pure.parts) + if marker is None: + if pure.is_relative_to(UNPUBLISHED_LOG_PREFIX): + raise LogNotAvailableError(LogUnavailableReason.NOT_PUBLISHED) + raise LogPathViolationError() + + suffix = pure.parts[marker + len(PUBLISHED_LOG_SEGMENTS):] + if ( + len(suffix) != 2 + or suffix[0] != job_id + or not SAFE_LOG_FILE_NAME.match(suffix[1]) + ): + raise LogPathViolationError() + + candidate = self._root / suffix[0] / suffix[1] + try: + resolved = candidate.resolve(strict=True) + except OSError as exc: + raise LogNotAvailableError(LogUnavailableReason.MISSING) from exc + if not resolved.is_relative_to(self._root) or not resolved.is_file(): + raise LogPathViolationError() + return resolved + + def read_tail(self, path: Path, lines: int, max_bytes: int) -> LogReadResult: + """Return the last ``lines`` lines of the file, bounded by ``max_bytes``.""" + with open(path, "rb") as handle: + size = os.fstat(handle.fileno()).st_size + data, start = _read_tail_bytes(handle, size, lines, max_bytes) + total = self._total_lines(handle, path, size) + _reject_binary(data) + text_lines = _split_lines(data, drop_partial_head=start > 0) + selected = text_lines[-lines:] if lines < len(text_lines) else text_lines + return LogReadResult( + lines=selected, + total_lines=total, + truncated=len(selected) < total, + ) + + def read_full(self, path: Path, max_bytes: int) -> LogReadResult: + """Return the whole file, keeping only the last ``max_bytes`` bytes.""" + with open(path, "rb") as handle: + size = os.fstat(handle.fileno()).st_size + start = max(0, size - max_bytes) + handle.seek(start) + data = handle.read(size - start) + total = self._total_lines(handle, path, size) + _reject_binary(data) + text_lines = _split_lines(data, drop_partial_head=start > 0) + return LogReadResult( + lines=text_lines, + total_lines=total, + truncated=start > 0, + ) + + def count_lines(self, path: Path) -> int: + """Return the number of lines in the file (cached by size and mtime).""" + with open(path, "rb") as handle: + size = os.fstat(handle.fileno()).st_size + return self._total_lines(handle, path, size) + + def _total_lines(self, handle: BinaryIO, path: Path, size: int) -> int: + """Count lines in the first ``size`` bytes, reusing a cached value when unchanged.""" + key = (str(path), size, os.fstat(handle.fileno()).st_mtime_ns) + with self._lock: + cached = self._line_cache.get(key) + if cached is not None: + self._line_cache.move_to_end(key) + return cached + count = _count_lines(handle, size) + with self._lock: + self._line_cache[key] = count + while len(self._line_cache) > self._line_cache_size: + self._line_cache.popitem(last=False) + return count + + +def _last_marker_index(parts: Tuple[str, ...]) -> Optional[int]: + """Return the index of the last ``build_stream/log`` segment pair, if any.""" + width = len(PUBLISHED_LOG_SEGMENTS) + for index in range(len(parts) - width, -1, -1): + if parts[index:index + width] == PUBLISHED_LOG_SEGMENTS: + return index + return None + + +def _read_tail_bytes( + handle: BinaryIO, size: int, lines: int, max_bytes: int +) -> Tuple[bytes, int]: + """Read backwards from ``size`` until ``lines`` complete lines or ``max_bytes``.""" + position = size + data = b"" + while position > 0 and data.count(b"\n") <= lines and len(data) < max_bytes: + chunk = min(READ_CHUNK_BYTES, position, max_bytes - len(data)) + position -= chunk + handle.seek(position) + data = handle.read(chunk) + data + return data, position + + +def _count_lines(handle: BinaryIO, size: int) -> int: + """Count newline-terminated lines plus a final unterminated line.""" + handle.seek(0) + remaining = size + count = 0 + last = b"" + while remaining > 0: + chunk = handle.read(min(READ_CHUNK_BYTES, remaining)) + if not chunk: + break + count += chunk.count(b"\n") + last = chunk[-1:] + remaining -= len(chunk) + if size > 0 and last != b"\n": + count += 1 + return count + + +def _reject_binary(data: bytes) -> None: + """Refuse to serve content that contains NUL bytes.""" + if b"\x00" in data: + raise LogContentError() + + +def _split_lines(data: bytes, drop_partial_head: bool) -> List[str]: + """Decode bytes and split into lines, dropping a partial first line when cut.""" + text = data.decode("utf-8", errors="replace") + lines = text.split("\n") + if lines and lines[-1] == "": + lines.pop() + if drop_partial_head and lines: + lines.pop(0) + return lines diff --git a/src/build_stream/app/orchestrator/logs/__init__.py b/src/build_stream/app/orchestrator/logs/__init__.py new file mode 100644 index 0000000000..878af95b1b --- /dev/null +++ b/src/build_stream/app/orchestrator/logs/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pipeline log query orchestration (ER-BSM-003).""" diff --git a/src/build_stream/app/orchestrator/logs/use_cases/__init__.py b/src/build_stream/app/orchestrator/logs/use_cases/__init__.py new file mode 100644 index 0000000000..a601ce6f88 --- /dev/null +++ b/src/build_stream/app/orchestrator/logs/use_cases/__init__.py @@ -0,0 +1,37 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Use cases for pipeline log queries.""" + +from orchestrator.logs.use_cases.query_logs import ( + LogQueryCommand, + LogQueryResult, + LogQuerySettings, + LogQueryUseCase, + StageLogResult, +) +from orchestrator.logs.use_cases.stage_log_metadata import ( + StageLogMetadata, + StageLogMetadataService, +) + +__all__ = [ + "LogQueryCommand", + "LogQueryResult", + "LogQuerySettings", + "LogQueryUseCase", + "StageLogResult", + "StageLogMetadata", + "StageLogMetadataService", +] diff --git a/src/build_stream/app/orchestrator/logs/use_cases/query_logs.py b/src/build_stream/app/orchestrator/logs/use_cases/query_logs.py new file mode 100644 index 0000000000..7fc70ebc3d --- /dev/null +++ b/src/build_stream/app/orchestrator/logs/use_cases/query_logs.py @@ -0,0 +1,225 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-AUTH-001.2, CG-INPUT-001.5, CG-LOGGING-001.2, CG-OUTPUT-001.3 +# **** + +"""LogQueryUseCase: serve redacted stage logs for an owned job (ER-BSM-003 FR-2.1, FR-3.2).""" + +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +from api.logging_utils import log_secure_info +from core.jobs.entities.stage import Stage +from core.jobs.exceptions import JobNotFoundError +from core.jobs.repositories import JobRepository, StageRepository +from core.jobs.value_objects import ClientId, JobId, StageState +from core.logs.exceptions import ( + InvalidLogQueryError, + LogNotAvailableError, + LogPathViolationError, + LogUnavailableReason, + StageNotFoundForJobError, +) +from core.logs.redaction import RedactionService, RedactionTier +from core.logs.services import LogFileService, LogReadResult + +ACTIVE_STAGE_STATES = frozenset({StageState.PENDING, StageState.IN_PROGRESS}) + + +@dataclass(frozen=True) +class LogQuerySettings: + """Bounds applied to every log query.""" + + default_tail_lines: int = 50 + max_tail_lines: int = 10000 + max_response_bytes: int = 10 * 1024 * 1024 + + +@dataclass(frozen=True) +class LogQueryCommand: + """A validated log query request.""" + + job_id: JobId + client_id: ClientId + correlation_id: str + stage: Optional[str] = None + tail_lines: Optional[int] = None + full: bool = False + + +@dataclass(frozen=True) +class StageLogResult: # pylint: disable=too-many-instance-attributes + """Log content (or the reason it is unavailable) for one stage.""" + + stage_name: str + stage_state: str + has_log: bool + log_content: Optional[str] + total_lines: int + returned_lines: int + truncated: bool + unavailable_reason: Optional[str] = None + + +@dataclass(frozen=True) +class LogQueryResult: + """Log query outcome for a job.""" + + job_id: str + tail_lines: Optional[int] + full: bool + stages: List[StageLogResult] + + +class LogQueryUseCase: # pylint: disable=too-few-public-methods + """Resolve, read, and redact stage logs for a job owned by the caller.""" + + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + job_repo: JobRepository, + stage_repo: StageRepository, + log_files: LogFileService, + redaction: RedactionService, + settings: LogQuerySettings, + ) -> None: + """Inject repositories, the log reader, redaction, and query bounds.""" + self._job_repo = job_repo + self._stage_repo = stage_repo + self._log_files = log_files + self._redaction = redaction + self._settings = settings + + def execute(self, command: LogQueryCommand) -> LogQueryResult: + """Run the query. + + Raises: + InvalidLogQueryError: Parameters are out of range or conflicting. + JobNotFoundError: Job is missing, tombstoned, or owned by another client. + StageNotFoundForJobError: The requested stage does not exist for the job. + LogNotAvailableError: A single requested stage has no servable log. + LogPathViolationError: A stored path escapes the log root. + """ + tail_lines = self._effective_tail_lines(command) + job = self._job_repo.find_by_id(command.job_id) + if job is None or job.tombstoned or job.client_id != command.client_id: + raise JobNotFoundError(str(command.job_id), command.correlation_id) + + stages = self._stage_repo.find_all_by_job(command.job_id) + if command.stage is not None: + stages = [s for s in stages if str(s.stage_name) == command.stage] + if not stages: + raise StageNotFoundForJobError() + + results = [self._read_stage(command, stage, tail_lines) for stage in stages] + if command.stage is not None and not results[0].has_log: + raise LogNotAvailableError(LogUnavailableReason(results[0].unavailable_reason)) + return LogQueryResult( + job_id=str(command.job_id), + tail_lines=None if command.full else tail_lines, + full=command.full, + stages=results, + ) + + def _effective_tail_lines(self, command: LogQueryCommand) -> int: + """Validate parameters and return the number of tail lines to serve.""" + if command.full and command.tail_lines is not None: + raise InvalidLogQueryError( + "INVALID_PARAMETERS", "full and tail_lines are mutually exclusive" + ) + if command.tail_lines is None: + return self._settings.default_tail_lines + if not 1 <= command.tail_lines <= self._settings.max_tail_lines: + raise InvalidLogQueryError( + "INVALID_TAIL_LINES", + f"tail_lines must be between 1 and {self._settings.max_tail_lines}", + ) + return command.tail_lines + + def _read_stage( + self, command: LogQueryCommand, stage: Stage, tail_lines: int + ) -> StageLogResult: + """Read and redact one stage log, or describe why it is unavailable.""" + job_id = str(command.job_id) + try: + if not stage.log_file_path: + reason = ( + LogUnavailableReason.NOT_YET_AVAILABLE + if stage.stage_state in ACTIVE_STAGE_STATES + else LogUnavailableReason.NOT_RECORDED + ) + raise LogNotAvailableError(reason) + path = self._log_files.resolve(stage.log_file_path, job_id) + read = self._read(path, command.full, tail_lines) + except LogPathViolationError: + log_secure_info( + "warning", + f"Security event: log path violation for stage={stage.stage_name}; " + f"stored path rejected, correlation_id={command.correlation_id}", + identifier=command.client_id.value, + job_id=job_id, + ) + raise + except LogNotAvailableError as exc: + return _unavailable(stage, exc.reason) + except OSError: + return _unavailable(stage, LogUnavailableReason.MISSING) + + redacted = self._redaction.redact_lines(read.lines, RedactionTier.SECRETS_ONLY) + if redacted.redacted_lines: + log_secure_info( + "info", + f"Response-time redaction applied: stage={stage.stage_name}, " + f"lines_redacted={redacted.redacted_lines}", + job_id=job_id, + ) + if redacted.suppressed_lines: + log_secure_info( + "warning", + f"Security event: redaction failed, lines withheld: stage={stage.stage_name}, " + f"lines_suppressed={redacted.suppressed_lines}", + job_id=job_id, + ) + return StageLogResult( + stage_name=str(stage.stage_name), + stage_state=stage.stage_state.value, + has_log=True, + log_content="\n".join(redacted.lines), + total_lines=read.total_lines, + returned_lines=len(redacted.lines), + truncated=read.truncated, + ) + + def _read(self, path: Path, full: bool, tail_lines: int) -> LogReadResult: + """Read either the bounded full log or its tail.""" + if full: + return self._log_files.read_full(path, self._settings.max_response_bytes) + return self._log_files.read_tail(path, tail_lines, self._settings.max_response_bytes) + + +def _unavailable(stage: Stage, reason: LogUnavailableReason) -> StageLogResult: + """Build a result entry for a stage whose log cannot be served.""" + return StageLogResult( + stage_name=str(stage.stage_name), + stage_state=stage.stage_state.value, + has_log=False, + log_content=None, + total_lines=0, + returned_lines=0, + truncated=False, + unavailable_reason=reason.value, + ) diff --git a/src/build_stream/app/orchestrator/logs/use_cases/stage_log_metadata.py b/src/build_stream/app/orchestrator/logs/use_cases/stage_log_metadata.py new file mode 100644 index 0000000000..716f17ddd6 --- /dev/null +++ b/src/build_stream/app/orchestrator/logs/use_cases/stage_log_metadata.py @@ -0,0 +1,70 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# **** +# Secure code generated utilizing Code Guardian. +# Applicable rules used: CG-INPUT-001.5, CG-OUTPUT-001.3 +# **** + +"""StageLogMetadataService: has_log / log_lines for GET /jobs/{id} (ER-BSM-003 FR-2.2).""" + +from dataclasses import dataclass + +from api.logging_utils import log_secure_info +from core.jobs.entities.stage import Stage +from core.jobs.value_objects import StageState +from core.logs.exceptions import LogNotAvailableError, LogPathViolationError +from core.logs.services import LogFileService + +NO_LOG_STATES = frozenset({StageState.PENDING, StageState.IN_PROGRESS}) + + +@dataclass(frozen=True) +class StageLogMetadata: + """Log availability for one stage.""" + + has_log: bool + log_lines: int + + +NO_LOG = StageLogMetadata(has_log=False, log_lines=0) + + +class StageLogMetadataService: # pylint: disable=too-few-public-methods + """Describe whether a stage has a servable log and how long it is. + + Never raises: the job detail endpoint must not fail because a log is missing. + """ + + def __init__(self, log_files: LogFileService) -> None: + """Inject the log reader.""" + self._log_files = log_files + + def describe(self, job_id: str, stage: Stage) -> StageLogMetadata: + """Return log metadata for a stage of ``job_id``.""" + if not stage.log_file_path or stage.stage_state in NO_LOG_STATES: + return NO_LOG + try: + path = self._log_files.resolve(stage.log_file_path, job_id) + return StageLogMetadata(has_log=True, log_lines=self._log_files.count_lines(path)) + except LogPathViolationError: + log_secure_info( + "warning", + f"Security event: log path violation for stage={stage.stage_name}; " + "log metadata withheld", + job_id=job_id, + ) + except (LogNotAvailableError, OSError): + pass + return NO_LOG diff --git a/test/build_stream/nft/unit/api/logs/__init__.py b/test/build_stream/nft/unit/api/logs/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/test/build_stream/nft/unit/api/logs/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/test/build_stream/nft/unit/api/logs/conftest.py b/test/build_stream/nft/unit/api/logs/conftest.py new file mode 100644 index 0000000000..9d2505f207 --- /dev/null +++ b/test/build_stream/nft/unit/api/logs/conftest.py @@ -0,0 +1,191 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=C0413,C0411,W0621,C0415,W0105,R0913,R0917 +# JSONB shim for SQLite compatibility - must precede app imports. +import sqlalchemy.dialects.sqlite.base as sqlite_base +from sqlalchemy import JSON +if not hasattr(sqlite_base, "JSONB"): + sqlite_base.JSONB = JSON + +# PyJWT compatibility shim, mirroring the Jobs API test fixtures. +import jwt.exceptions +for _name in ("ExpiredSignatureError", "InvalidAudienceError", "InvalidIssuerError", + "InvalidSignatureError"): + if not hasattr(jwt.exceptions, _name): + setattr(jwt.exceptions, _name, type(_name, (jwt.exceptions.JWTException,), {})) +if not hasattr(jwt.exceptions, "DecodeError"): + jwt.exceptions.DecodeError = jwt.exceptions.JWTDecodeError + +"""Shared fixtures for the pipeline log query API tests (ER-BSM-003).""" + +import os +import uuid +from pathlib import Path +from typing import Callable, Dict, Optional + +import pytest +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi.testclient import TestClient + +os.environ.setdefault("ENV", "dev") + +from main import app +from api.dependencies import get_audit_repo, get_job_repo, get_stage_repo, verify_token +from api.logs.dependencies import ( + get_concurrency_limiter, + get_log_file_service, + get_rate_limiter, +) +from api.logs.limiter import ConcurrencyLimiter, SlidingWindowRateLimiter +from core.jobs.entities.job import Job +from core.jobs.entities.stage import Stage +from core.jobs.value_objects import ClientId, JobId, StageName, StageState, StageType +from infra.logs.filesystem_adapter import FileSystemLogAdapter +from infra.repositories.in_memory import ( + InMemoryAuditEventRepository, + InMemoryJobRepository, + InMemoryStageRepository, +) + +HOST_LOG_PREFIX = "/mnt/host_share/build_stream/log" + +_bearer = HTTPBearer(auto_error=False) + + +def _mock_verify_token( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer), +) -> dict: + """Use the bearer value as the client_id.""" + if credentials is None or not credentials.credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "missing_token", + "error_description": "Authorization header is required", + }, + headers={"WWW-Authenticate": "Bearer"}, + ) + return { + "client_id": credentials.credentials, + "client_name": credentials.credentials, + "scopes": ["job:write", "job:read"], + "token_id": "test-token-id", + } + + +@pytest.fixture +def log_root(tmp_path) -> Path: + """Container-side log root for the test.""" + root = tmp_path / "opt" / "omnia" / "build_stream" / "log" + root.mkdir(parents=True) + return root + + +@pytest.fixture +def rate_limit() -> Dict[str, int]: + """Mutable rate limit used by the client fixture (tests may lower it).""" + return {"per_minute": 60, "concurrent": 5} + + +@pytest.fixture +def job_repo() -> InMemoryJobRepository: + """Per-test job repository (isolated from the shared container).""" + return InMemoryJobRepository() + + +@pytest.fixture +def stage_repo() -> InMemoryStageRepository: + """Per-test stage repository (isolated from the shared container).""" + return InMemoryStageRepository() + + +@pytest.fixture +def client(log_root, rate_limit, job_repo, stage_repo): + """TestClient with mocked auth, isolated repositories, a log root, and fresh limiters.""" + adapter = FileSystemLogAdapter(log_root) + limiter = SlidingWindowRateLimiter(rate_limit["per_minute"]) + concurrency = ConcurrencyLimiter(rate_limit["concurrent"]) + audit_repo = InMemoryAuditEventRepository() + app.dependency_overrides[verify_token] = _mock_verify_token + app.dependency_overrides[get_job_repo] = lambda: job_repo + app.dependency_overrides[get_stage_repo] = lambda: stage_repo + app.dependency_overrides[get_audit_repo] = lambda: audit_repo + app.dependency_overrides[get_log_file_service] = lambda: adapter + app.dependency_overrides[get_rate_limiter] = lambda: limiter + app.dependency_overrides[get_concurrency_limiter] = lambda: concurrency + yield TestClient(app) + app.dependency_overrides.clear() + + +@pytest.fixture +def unauth_client(): + """TestClient using the real JWT dependency.""" + return TestClient(app) + + +@pytest.fixture +def owner() -> str: + """Unique client identifier that owns the test job.""" + return f"client-{uuid.uuid4().hex[:8]}" + + +@pytest.fixture +def headers(owner) -> Dict[str, str]: + """Authorization headers for the owning client.""" + return {"Authorization": f"Bearer {owner}"} + + +@pytest.fixture +def create_job(owner, job_repo, stage_repo) -> Callable[[], str]: + """Create a job owned by ``owner`` with every stage PENDING and return its ID.""" + def _create() -> str: + job_id = JobId(str(uuid.uuid4())) + job_repo.save(Job(job_id=job_id, client_id=ClientId(owner), request_client_id=owner)) + stage_repo.save_all([ + Stage(job_id=job_id, stage_name=StageName(stage_type.value)) + for stage_type in StageType + ]) + return str(job_id) + return _create + + +@pytest.fixture +def publish_log(log_root, stage_repo) -> Callable[..., str]: + """Write a stage log under the log root and mark the stage as completed with it.""" + def _publish( + job_id: str, + stage_name: str, + content: Optional[str] = None, + raw: Optional[bytes] = None, + state: StageState = StageState.COMPLETED, + stored_path: Optional[str] = None, + ) -> str: + file_name = f"{stage_name}_playbook.yml_20261113_101500_attempt1.log" + if content is not None or raw is not None: + job_dir = log_root / job_id + job_dir.mkdir(exist_ok=True) + target = job_dir / file_name + if raw is not None: + target.write_bytes(raw) + else: + target.write_text(content, encoding="utf-8") + stage = stage_repo.find_by_job_and_name(JobId(job_id), StageName(stage_name)) + stage.stage_state = state + stage.log_file_path = stored_path or f"{HOST_LOG_PREFIX}/{job_id}/{file_name}" + stage_repo.save(stage) + return file_name + + return _publish diff --git a/test/build_stream/nft/unit/api/logs/test_get_job_log_metadata.py b/test/build_stream/nft/unit/api/logs/test_get_job_log_metadata.py new file mode 100644 index 0000000000..773a2f4f5b --- /dev/null +++ b/test/build_stream/nft/unit/api/logs/test_get_job_log_metadata.py @@ -0,0 +1,77 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""GET /api/v1/jobs/{job_id} stage log metadata: has_log / log_lines (ER-BSM-003 FR-2.2).""" + +import pytest + +from core.jobs.value_objects import StageState + +pytestmark = pytest.mark.unit + + +def _stage(body: dict, name: str) -> dict: + """Pick one stage entry from a job response.""" + return next(s for s in body["stages"] if s["stage_name"] == name) + + +class TestJobStageLogMetadata: + """FR-2.2 scenarios against the existing job detail endpoint.""" + + def test_has_log_and_line_counts(self, client, headers, create_job, publish_log): + """GIVEN published logs for two stages THEN has_log and log_lines are reported.""" + job_id = create_job() + publish_log(job_id, "parse-catalog", "x\n" * 245) + publish_log(job_id, "build-image", "y\n" * 1830) + + body = client.get(f"/api/v1/jobs/{job_id}", headers=headers).json() + + assert _stage(body, "parse-catalog")["has_log"] is True + assert _stage(body, "parse-catalog")["log_lines"] == 245 + assert _stage(body, "build-image")["log_lines"] == 1830 + assert _stage(body, "deploy")["has_log"] is False + assert _stage(body, "deploy")["log_lines"] == 0 + + def test_deleted_log_file_reports_no_log(self, client, headers, create_job, publish_log): + """GIVEN a recorded log path whose file was deleted THEN has_log=false, log_lines=0.""" + job_id = create_job() + publish_log(job_id, "deploy") + + entry = _stage(client.get(f"/api/v1/jobs/{job_id}", headers=headers).json(), "deploy") + + assert entry["has_log"] is False + assert entry["log_lines"] == 0 + + def test_running_stage_reports_no_log(self, client, headers, create_job, publish_log): + """GIVEN a stage still in progress THEN has_log=false.""" + job_id = create_job() + publish_log(job_id, "build-image", "partial\n", state=StageState.IN_PROGRESS) + + entry = _stage( + client.get(f"/api/v1/jobs/{job_id}", headers=headers).json(), "build-image" + ) + + assert entry["has_log"] is False + + def test_existing_fields_preserved(self, client, headers, create_job, publish_log): + """Backward compatibility: log_file_path and other fields are still returned.""" + job_id = create_job() + name = publish_log(job_id, "build-image", "ok\n") + + entry = _stage( + client.get(f"/api/v1/jobs/{job_id}", headers=headers).json(), "build-image" + ) + + assert entry["log_file_path"].endswith(name) + assert entry["stage_state"] == "COMPLETED" diff --git a/test/build_stream/nft/unit/api/logs/test_log_limiter.py b/test/build_stream/nft/unit/api/logs/test_log_limiter.py new file mode 100644 index 0000000000..dfedfa466f --- /dev/null +++ b/test/build_stream/nft/unit/api/logs/test_log_limiter.py @@ -0,0 +1,149 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""TC-UT-007 / TC-UT-008: log query rate and concurrency limiters (ER-BSM-003 HLD 6.2).""" + +import threading +import time +from contextlib import ExitStack + +import pytest + +from api.logs.limiter import ( + ConcurrencyLimiter, + ConcurrencyLimitExceededError, + RateLimitExceededError, + SlidingWindowRateLimiter, +) + +pytestmark = pytest.mark.unit + + +class _Clock: # pylint: disable=too-few-public-methods + """Manually advanced monotonic clock.""" + + def __init__(self) -> None: + """Start at t=1000.""" + self.now = 1000.0 + + def __call__(self) -> float: + """Return the current time.""" + return self.now + + +class TestSlidingWindowRateLimiter: + """TC-UT-007.""" + + def test_requests_within_limit_pass(self): + """GIVEN 59 requests in the window WHEN request 60 arrives THEN it is allowed.""" + limiter = SlidingWindowRateLimiter(60, clock=_Clock()) + for _ in range(60): + limiter.check("client-A") + + def test_request_over_limit_rejected_with_retry_after(self): + """GIVEN 60 requests in the window WHEN request 61 arrives THEN it is rejected.""" + clock = _Clock() + limiter = SlidingWindowRateLimiter(60, clock=clock) + for _ in range(60): + limiter.check("client-A") + clock.now += 15 + with pytest.raises(RateLimitExceededError) as exc: + limiter.check("client-A") + assert exc.value.retry_after == 45 + + def test_window_slides(self): + """Requests older than the window stop counting.""" + clock = _Clock() + limiter = SlidingWindowRateLimiter(2, clock=clock) + limiter.check("c") + limiter.check("c") + clock.now += 60 + limiter.check("c") + + def test_limits_are_per_client(self): + """One client's budget does not affect another's.""" + limiter = SlidingWindowRateLimiter(1, clock=_Clock()) + limiter.check("client-A") + limiter.check("client-B") + with pytest.raises(RateLimitExceededError): + limiter.check("client-A") + + def test_stale_keys_pruned_when_table_full(self): + """Idle keys are evicted so the key table stays bounded.""" + clock = _Clock() + limiter = SlidingWindowRateLimiter(5, clock=clock, max_keys=2) + limiter.check("a") + limiter.check("b") + clock.now += 120 + limiter.check("c") + assert set(limiter._events) == {"c"} # pylint: disable=protected-access + + +class TestConcurrencyLimiter: + """TC-UT-008.""" + + def test_requests_within_limit_pass(self): + """GIVEN 4 in-flight requests WHEN request 5 arrives THEN it is allowed.""" + limiter = ConcurrencyLimiter(5) + with ExitStack() as stack: + for _ in range(5): + stack.enter_context(limiter.slot()) + + def test_request_over_limit_rejected(self): + """GIVEN 5 in-flight requests WHEN request 6 arrives THEN it is rejected.""" + limiter = ConcurrencyLimiter(5) + with ExitStack() as stack: + for _ in range(5): + stack.enter_context(limiter.slot()) + with pytest.raises(ConcurrencyLimitExceededError): + with limiter.slot(): + pass + + def test_slot_released_after_exception(self): + """A failing request releases its slot.""" + limiter = ConcurrencyLimiter(1) + with pytest.raises(RuntimeError): + with limiter.slot(): + raise RuntimeError("boom") + with limiter.slot(): + pass + + def test_thread_safety(self): + """Concurrent threads never exceed the slot count.""" + limiter = ConcurrencyLimiter(3) + barrier = threading.Barrier(6) + admitted = [] + rejected = [] + release = threading.Event() + + def worker(): + barrier.wait() + try: + with limiter.slot(): + admitted.append(1) + release.wait(2) + except ConcurrencyLimitExceededError: + rejected.append(1) + + threads = [threading.Thread(target=worker) for _ in range(6)] + for thread in threads: + thread.start() + deadline = time.monotonic() + 5 + while len(admitted) + len(rejected) < 6 and time.monotonic() < deadline: + time.sleep(0.01) + release.set() + for thread in threads: + thread.join() + assert len(admitted) == 3 + assert len(rejected) == 3 diff --git a/test/build_stream/nft/unit/api/logs/test_logs_api.py b/test/build_stream/nft/unit/api/logs/test_logs_api.py new file mode 100644 index 0000000000..4bfdca7d9b --- /dev/null +++ b/test/build_stream/nft/unit/api/logs/test_logs_api.py @@ -0,0 +1,283 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""API contract tests for GET /api/v1/jobs/{job_id}/logs (ER-BSM-003 FR-2.1, FR-3.2). + +Secret-looking values are synthetic fixtures, not real credentials. +""" + +# pylint: disable=too-few-public-methods,too-many-arguments,too-many-positional-arguments + +import uuid + +import pytest + +from core.jobs.value_objects import JobId, StageName, StageState +from core.logs.redaction_patterns import REDACTED + +pytestmark = pytest.mark.unit + + +def _lines(count: int) -> str: + """Numbered log lines with trailing newline.""" + return "".join(f"line {i}\n" for i in range(1, count + 1)) + + +def _stage(body: dict, name: str) -> dict: + """Pick one stage entry from a response body.""" + return next(s for s in body["stages"] if s["stage_name"] == name) + + +class TestLogQuerySuccess: + """Default tail, custom tail, full, and stage filter.""" + + def test_default_tail_applied_to_each_stage(self, client, headers, create_job, publish_log): + """GIVEN two published stage logs WHEN GET /logs THEN 50 lines per stage are returned.""" + job_id = create_job() + publish_log(job_id, "parse-catalog", _lines(245)) + publish_log(job_id, "build-image", _lines(1830)) + + response = client.get(f"/api/v1/jobs/{job_id}/logs", headers=headers) + + assert response.status_code == 200 + body = response.json() + assert body["tail_lines"] == 50 + build = _stage(body, "build-image") + assert build["has_log"] is True + assert build["returned_lines"] == 50 + assert build["total_lines"] == 1830 + assert build["truncated"] is True + assert build["log_content"].splitlines()[-1] == "line 1830" + assert _stage(body, "parse-catalog")["total_lines"] == 245 + pending = _stage(body, "deploy") + assert pending["has_log"] is False + assert pending["unavailable_reason"] == "NOT_YET_AVAILABLE" + + def test_custom_tail_lines(self, client, headers, create_job, publish_log): + """GIVEN tail_lines=100 THEN the last 100 lines are returned, or all when shorter.""" + job_id = create_job() + publish_log(job_id, "build-image", _lines(1830)) + publish_log(job_id, "parse-catalog", _lines(30)) + + body = client.get( + f"/api/v1/jobs/{job_id}/logs?tail_lines=100", headers=headers + ).json() + + assert _stage(body, "build-image")["returned_lines"] == 100 + short = _stage(body, "parse-catalog") + assert short["returned_lines"] == 30 + assert short["truncated"] is False + + def test_full_log_for_stage(self, client, headers, create_job, publish_log): + """GIVEN full=true and stage THEN the whole stage log is returned untruncated.""" + job_id = create_job() + publish_log(job_id, "build-image", _lines(1830)) + + response = client.get( + f"/api/v1/jobs/{job_id}/logs?stage=build-image&full=true", headers=headers + ) + + body = response.json() + assert response.status_code == 200 + assert body["full"] is True + assert body["tail_lines"] is None + entry = body["stages"][0] + assert entry["returned_lines"] == entry["total_lines"] == 1830 + assert entry["truncated"] is False + + def test_stage_filter_returns_one_entry(self, client, headers, create_job, publish_log): + """GIVEN stage=build-image THEN only that stage is returned.""" + job_id = create_job() + publish_log(job_id, "build-image", _lines(80)) + publish_log(job_id, "parse-catalog", _lines(10)) + + body = client.get( + f"/api/v1/jobs/{job_id}/logs?stage=build-image", headers=headers + ).json() + + assert [s["stage_name"] for s in body["stages"]] == ["build-image"] + assert body["stages"][0]["returned_lines"] == 50 + + +class TestLogQueryRedaction: + """Response-time redaction through the real stack (FR-3.2).""" + + def test_secrets_redacted_and_disk_unchanged( + self, client, headers, create_job, publish_log, log_root + ): + """GIVEN a log with secrets THEN the response is redacted and the file is untouched.""" + job_id = create_job() + dirty = ( + "TASK [deploy : configure] host 10.5.0.1\n" + "password=cleartext\n" + '{"gitlab_api_token": "glpat-abc123secret"}\n' # gitleaks:allow - test fixture + "Authorization: Bearer eyJhbGciOiJSUzI1.eyJzdWIiOiJ4eXo.c2lnbmF0dXJl\n" + "-----BEGIN RSA PRIVATE KEY-----\nMIIEsecretbody\n-----END RSA PRIVATE KEY-----\n" + f"already {REDACTED}\n" + ) + name = publish_log(job_id, "build-image", dirty) + + body = client.get( + f"/api/v1/jobs/{job_id}/logs?stage=build-image&full=true", headers=headers + ).json() + + content = body["stages"][0]["log_content"] + for secret in ("cleartext", "glpat-abc123secret", "eyJhbGciOiJSUzI1", "MIIEsecretbody"): # gitleaks:allow - test fixture + assert secret not in content + assert "password=" + REDACTED in content + assert "host 10.5.0.1" in content + assert f"already {REDACTED}" in content + assert (log_root / job_id / name).read_text(encoding="utf-8") == dirty + + +class TestLogQueryErrors: + """Error contract from ER-BSM-003 FR-2.1 / HLD 4.3.""" + + def test_non_owned_job_returns_404(self, client, create_job, publish_log): + """GIVEN a job owned by another client THEN 404 Job not found.""" + job_id = create_job() + publish_log(job_id, "build-image", _lines(5)) + response = client.get( + f"/api/v1/jobs/{job_id}/logs", headers={"Authorization": "Bearer someone-else"} + ) + assert response.status_code == 404 + assert response.json()["detail"]["message"] == "Job not found" + + def test_unknown_job_returns_404(self, client, headers): + """GIVEN a well-formed but unknown job ID THEN 404.""" + response = client.get(f"/api/v1/jobs/{uuid.uuid4()}/logs", headers=headers) + assert response.status_code == 404 + assert response.json()["detail"]["error"] == "JOB_NOT_FOUND" + + def test_malformed_job_id_returns_400(self, client, headers): + """GIVEN a non-UUID job ID THEN 400 INVALID_JOB_ID (existing job API behaviour).""" + response = client.get("/api/v1/jobs/nonexistent/logs", headers=headers) + assert response.status_code == 400 + assert response.json()["detail"]["error"] == "INVALID_JOB_ID" + + def test_unknown_stage_returns_404(self, client, headers, create_job): + """GIVEN stage=nonexistent-stage THEN 404 Stage not found for job.""" + job_id = create_job() + response = client.get( + f"/api/v1/jobs/{job_id}/logs?stage=nonexistent-stage", headers=headers + ) + assert response.status_code == 404 + assert response.json()["detail"]["message"] == "Stage not found for job" + + def test_running_stage_returns_404_not_yet_available(self, client, headers, create_job): + """GIVEN a stage without a published log THEN 404 Stage log not yet available.""" + job_id = create_job() + response = client.get(f"/api/v1/jobs/{job_id}/logs?stage=deploy", headers=headers) + assert response.status_code == 404 + assert response.json()["detail"]["message"] == "Stage log not yet available" + + def test_unauthenticated_returns_401(self, unauth_client): + """GIVEN no JWT THEN 401 and no content.""" + response = unauth_client.get(f"/api/v1/jobs/{uuid.uuid4()}/logs") + assert response.status_code == 401 + assert "stages" not in response.json() + + @pytest.mark.parametrize("tail_lines", [0, 10001]) + def test_tail_lines_out_of_range_returns_400(self, client, headers, create_job, tail_lines): + """GIVEN tail_lines outside 1..10000 THEN 400 with the contract message.""" + job_id = create_job() + response = client.get( + f"/api/v1/jobs/{job_id}/logs?tail_lines={tail_lines}", headers=headers + ) + assert response.status_code == 400 + assert response.json()["detail"]["message"] == "tail_lines must be between 1 and 10000" + + def test_full_and_tail_lines_returns_400(self, client, headers, create_job): + """GIVEN full=true and tail_lines THEN 400 mutually exclusive.""" + job_id = create_job() + response = client.get( + f"/api/v1/jobs/{job_id}/logs?full=true&tail_lines=50", headers=headers + ) + assert response.status_code == 400 + assert response.json()["detail"]["message"] == "full and tail_lines are mutually exclusive" + + def test_path_escape_returns_409_without_path(self, client, headers, create_job, publish_log): + """GIVEN a stored path outside the log root THEN 409 and the path is not echoed.""" + job_id = create_job() + publish_log(job_id, "build-image", stored_path="/etc/passwd") + response = client.get(f"/api/v1/jobs/{job_id}/logs", headers=headers) + assert response.status_code == 409 + assert response.json()["detail"]["error"] == "LOG_PATH_VIOLATION" + assert "/etc/passwd" not in response.text + assert "root:" not in response.text + + def test_binary_log_returns_422(self, client, headers, create_job, publish_log): + """GIVEN a binary stage log THEN 422 LOG_CONTENT_UNREADABLE.""" + job_id = create_job() + publish_log(job_id, "build-image", raw=b"\x00\x01binary") + response = client.get( + f"/api/v1/jobs/{job_id}/logs?stage=build-image", headers=headers + ) + assert response.status_code == 422 + assert response.json()["detail"]["error"] == "LOG_CONTENT_UNREADABLE" + + def test_legacy_stage_without_path_returns_404( + self, client, headers, create_job, stage_repo + ): + """GIVEN a completed pre-ER-BSM-003 stage with no log path THEN 404 (no discovery).""" + job_id = create_job() + stage = stage_repo.find_by_job_and_name(JobId(job_id), StageName("build-image")) + stage.stage_state = StageState.COMPLETED + stage_repo.save(stage) + response = client.get( + f"/api/v1/jobs/{job_id}/logs?stage=build-image", headers=headers + ) + assert response.status_code == 404 + assert response.json()["detail"]["message"] == "Log file not available for this stage" + + +class TestLogQueryLimits: + """Rate limit (429) and concurrency limit (503).""" + + @pytest.fixture + def rate_limit(self): + """Lower limits so the test stays fast.""" + return {"per_minute": 3, "concurrent": 5} + + def test_rate_limit_returns_429(self, client, headers, create_job): + """GIVEN the per-client budget is used THEN 429 with Retry-After.""" + job_id = create_job() + for _ in range(3): + assert client.get(f"/api/v1/jobs/{job_id}/logs", headers=headers).status_code == 200 + response = client.get(f"/api/v1/jobs/{job_id}/logs", headers=headers) + assert response.status_code == 429 + assert response.json()["detail"]["message"] == "Rate limit exceeded" + assert int(response.headers["Retry-After"]) >= 1 + + +class TestConcurrencyLimit: + """503 when no concurrency slot is free.""" + + @pytest.fixture + def rate_limit(self): + """Allow a single concurrent request.""" + return {"per_minute": 60, "concurrent": 1} + + def test_concurrency_limit_returns_503(self, client, headers, create_job): + """GIVEN every slot is held THEN 503 with Retry-After.""" + from api.logs.dependencies import get_concurrency_limiter # pylint: disable=import-outside-toplevel + from main import app # pylint: disable=import-outside-toplevel + + job_id = create_job() + limiter = app.dependency_overrides[get_concurrency_limiter]() + with limiter.slot(): + response = client.get(f"/api/v1/jobs/{job_id}/logs", headers=headers) + assert response.status_code == 503 + assert response.headers["Retry-After"] == "1" + assert client.get(f"/api/v1/jobs/{job_id}/logs", headers=headers).status_code == 200 diff --git a/test/build_stream/nft/unit/common/test_log_query_config.py b/test/build_stream/nft/unit/common/test_log_query_config.py new file mode 100644 index 0000000000..a25c43fd82 --- /dev/null +++ b/test/build_stream/nft/unit/common/test_log_query_config.py @@ -0,0 +1,88 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""[log_query] section of build_stream.ini (ER-BSM-003).""" + +from dataclasses import asdict +from pathlib import Path + +import pytest + +from common.config import LogQueryConfig, load_config + +pytestmark = pytest.mark.unit + +BASE_INI = """ +[paths] +build_stream_base_path = ${OMNIA_DATA_PATH}/build_stream_root + +[artifact_store] +backend = file_store + +[file_store] +base_path = ${OMNIA_DATA_PATH}/build_stream_root/artifacts +""" + +REPO_INI = Path(__file__).resolve().parents[5] / "src" / "build_stream" / "app" / "build_stream.ini" + + +def _write(tmp_path, extra: str = "") -> str: + """Write an INI file and return its path.""" + path = tmp_path / "build_stream.ini" + path.write_text(BASE_INI + extra, encoding="utf-8") + return str(path) + + +class TestLogQueryConfig: + """Defaults, overrides, and validation.""" + + def test_missing_section_uses_defaults(self, tmp_path, monkeypatch): + """GIVEN no [log_query] section THEN built-in defaults apply.""" + monkeypatch.setenv("OMNIA_DATA_PATH", "/data") + config = load_config(_write(tmp_path)).log_query + assert asdict(config) == asdict(LogQueryConfig(log_root="/data/build_stream/log")) + + def test_overrides_and_env_expansion(self, tmp_path, monkeypatch): + """GIVEN explicit values THEN they are parsed and ${VAR} is expanded.""" + monkeypatch.setenv("OMNIA_DATA_PATH", "/data") + config = load_config(_write(tmp_path, ( + "[log_query]\nlog_root = ${OMNIA_DATA_PATH}/logs\ndefault_tail_lines = 25\n" + "max_tail_lines = 500\nmax_response_bytes = 2048\n" + "rate_limit_per_minute = 10\nmax_concurrent = 2\n" + ))).log_query + assert config.log_root == "/data/logs" + assert (config.default_tail_lines, config.max_tail_lines) == (25, 500) + assert config.max_response_bytes == 2048 + assert (config.rate_limit_per_minute, config.max_concurrent) == (10, 2) + + @pytest.mark.parametrize( + "line", + [ + "max_tail_lines = 20000", + "default_tail_lines = 0", + "max_response_bytes = 10", + "rate_limit_per_minute = 0", + "max_concurrent = 0", + "log_root = relative/path", + ], + ) + def test_invalid_values_rejected(self, tmp_path, line): + """GIVEN an unsafe value THEN loading fails instead of disabling a limit.""" + with pytest.raises(ValueError): + load_config(_write(tmp_path, f"[log_query]\n{line}\n")) + + def test_repository_ini_matches_defaults(self, monkeypatch): + """The shipped build_stream.ini documents exactly the built-in defaults.""" + monkeypatch.setenv("OMNIA_DATA_PATH", "/opt/omnia") + assert asdict(load_config(str(REPO_INI)).log_query) == asdict(LogQueryConfig()) diff --git a/test/build_stream/nft/unit/core/logs/__init__.py b/test/build_stream/nft/unit/core/logs/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/test/build_stream/nft/unit/core/logs/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/test/build_stream/nft/unit/core/logs/test_redaction.py b/test/build_stream/nft/unit/core/logs/test_redaction.py new file mode 100644 index 0000000000..e93cc6080b --- /dev/null +++ b/test/build_stream/nft/unit/core/logs/test_redaction.py @@ -0,0 +1,198 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""TC-UT-005: RedactionService tiered pattern application (ER-BSM-003 FR-3.2). + +Secret-looking values below are synthetic fixtures, not real credentials. +""" + +import re +import time + +import pytest + +from core.logs.redaction import RedactionService, RedactionTier +from core.logs.redaction_patterns import ( + REDACTED, + REDACTED_EMAIL, + REDACTED_IP, + REDACTION_SUPPRESSED_LINE, +) + +pytestmark = pytest.mark.unit + + +@pytest.fixture(name="service") +def service_fixture() -> RedactionService: + """Default redaction service.""" + return RedactionService() + + +class TestTierSelection: + """Tier 1 vs Tier 1 + Tier 2 behaviour.""" + + def test_secrets_only_redacts_token_and_keeps_ip(self, service): + """GIVEN a GitLab token and host IP WHEN secrets_only THEN only the token is hidden.""" + out = service.redact("token glpat-abc123secret on 192.168.1.100") + assert "glpat-abc123secret" not in out + assert REDACTED in out + assert "192.168.1.100" in out + + def test_full_tier_also_redacts_ip_and_email(self, service): + """GIVEN a token, IP, and e-mail WHEN full THEN all three are hidden.""" + out = service.redact( + "glpat-abc123secret 192.168.1.100 ops@example.com", RedactionTier.FULL + ) + assert "glpat-abc123secret" not in out + assert REDACTED_IP in out + assert REDACTED_EMAIL in out + + def test_default_tier_is_secrets_only(self, service): + """Default tier preserves operational identifiers (Tier 3).""" + assert service.redact("node 10.5.0.1 ready") == "node 10.5.0.1 ready" + + +class TestSecretPatterns: + """Each Tier 1 pattern from HLD 5.3.4.""" + + @pytest.mark.parametrize( + "line, leaked", + [ + ("Authorization: Bearer abc.def-ghi", "abc.def-ghi"), + ("jwt eyJhbGciOiJSUzI1.eyJzdWIiOiJjbGllbnQ.c2lnbmF0dXJlX2Jsb2I", "eyJhbGciOiJSUzI1"), + ("client_secret=hunter2", "hunter2"), + ("password=mysecretpassword", "mysecretpassword"), # gitleaks:allow - test fixture + ("passwd: plainvalue", "plainvalue"), # gitleaks:allow - test fixture + ('{"ansible_password": "s3cr3t-value", "changed": false}', "s3cr3t-value"), + ("api_key='k-123456'", "k-123456"), + ("postgresql://admin:dbpass123@localhost:5432/bsm", "dbpass123"), + ("runner glrt-AbCdEf123456789", "glrt-AbCdEf123456789"), + ], + ) + def test_secret_values_are_redacted(self, service, line, leaked): + """GIVEN a secret-bearing line WHEN redacted THEN the secret value is removed.""" + out = service.redact(line) + assert leaked not in out + assert REDACTED in out + + def test_quoted_value_keeps_quotes(self, service): + """JSON structure remains valid after redaction.""" + out = service.redact('{"gitlab_root_password": "abc"}') + assert out == '{"gitlab_root_password": "' + REDACTED + '"}' + + def test_db_url_keeps_user_and_host(self, service): + """Only the credential portion of a URL is removed.""" + out = service.redact("postgresql://admin:dbpass123@localhost:5432/bsm") + assert out == f"postgresql://admin:{REDACTED}@localhost:5432/bsm" + + def test_inline_private_key_redacted(self, service): + """A JSON-escaped PEM key on one line is removed entirely.""" + line = 'key: "-----BEGIN RSA PRIVATE KEY-----\\nMIIEabc\\n-----END RSA PRIVATE KEY-----" x' # gitleaks:allow - test fixture + out = service.redact(line) + assert "MIIEabc" not in out + assert out.endswith(" x") + + @pytest.mark.parametrize( + "line", + [ + "TASK [hosted_gitlab : Change GitLab root password] ****", + "Installed glibc-minimal-langpack-2.39 on 10.5.0.1", + "ok: [oim] => (item=gitlab_root_password)", + "PLAY RECAP oim : ok=12 changed=3 unreachable=0 failed=0", + ], + ) + def test_operational_content_preserved(self, service, line): + """GIVEN task names, packages, and IPs WHEN redacted THEN they stay verbatim.""" + assert service.redact(line) == line + + +class TestMultiLinePrivateKey: + """PEM blocks that span lines, including windows starting mid-block.""" + + def test_full_block_redacted_and_following_line_kept(self, service): + """BEGIN..END lines are redacted; subsequent output is preserved.""" + lines = [ + "before", + "-----BEGIN EC PRIVATE KEY-----", + "MHcCAQEEIBody", + "-----END EC PRIVATE KEY-----", + "after", + ] + result = service.redact_lines(lines) + assert result.lines == ["before", REDACTED, REDACTED, REDACTED, "after"] + assert result.redacted_lines == 3 + + def test_window_starting_inside_block(self, service): + """GIVEN a tail window that begins inside a key WHEN redacted THEN body lines are hidden.""" + lines = ["MIIEbody1", "MIIEbody2", "-----END RSA PRIVATE KEY-----", "after"] + result = service.redact_lines(lines) + assert result.lines == [REDACTED, REDACTED, REDACTED, "after"] + + def test_unterminated_block_redacts_remaining_lines(self, service): + """A block without END redacts every remaining line (fail closed).""" + lines = ["-----BEGIN OPENSSH PRIVATE KEY-----", "b3BlbnNzaC1rZXk", "more"] # gitleaks:allow - test fixture + assert service.redact_lines(lines).lines == [REDACTED, REDACTED, REDACTED] + + +class TestIdempotencyAndCounting: + """Second-pass safety and counters used for NFR-4 logging.""" + + @pytest.mark.parametrize( + "line", + [ + f"password={REDACTED}", + f'"password": "{REDACTED}"', + f"Authorization: Bearer {REDACTED}", + f"postgresql://admin:{REDACTED}@db", # gitleaks:allow - test fixture + REDACTED, + ], + ) + def test_already_redacted_content_unchanged(self, service, line): + """GIVEN write-time markers WHEN redacted again THEN the line is unchanged.""" + result = service.redact_lines([line]) + assert result.lines == [line] + assert result.redacted_lines == 0 + + def test_counts_only_changed_lines(self, service): + """redacted_lines counts lines whose content changed.""" + result = service.redact_lines(["ok", "password=x", "token: y", "fine"]) + assert result.redacted_lines == 2 + assert result.suppressed_lines == 0 + + +class _ExplodingPattern: # pylint: disable=too-few-public-methods + """Pattern stand-in whose substitution fails like a runaway regex.""" + + def sub(self, _replacement, _line): + """Raise the error the service must contain.""" + raise re.error("catastrophic backtracking") + + +class TestFailClosed: + """Redaction failures never leak the original line.""" + + def test_failing_pattern_suppresses_line(self): + """GIVEN a pattern that errors WHEN redacting THEN the line is withheld.""" + service = RedactionService(secret_rules=[(_ExplodingPattern(), REDACTED)]) + result = service.redact_lines(["password=leak", "other"]) + assert result.lines == [REDACTION_SUPPRESSED_LINE, REDACTION_SUPPRESSED_LINE] + assert result.suppressed_lines == 2 + assert "leak" not in "".join(result.lines) + + def test_long_hostile_line_is_bounded(self, service): + """Bounded quantifiers keep a 400 KB adversarial line fast.""" + hostile = "a" * 200_000 + "password" + "b" * 200_000 + started = time.monotonic() + service.redact(hostile) + assert time.monotonic() - started < 2.0 diff --git a/test/build_stream/nft/unit/infra/logs/__init__.py b/test/build_stream/nft/unit/infra/logs/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/test/build_stream/nft/unit/infra/logs/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/test/build_stream/nft/unit/infra/logs/test_filesystem_log_adapter.py b/test/build_stream/nft/unit/infra/logs/test_filesystem_log_adapter.py new file mode 100644 index 0000000000..5858803063 --- /dev/null +++ b/test/build_stream/nft/unit/infra/logs/test_filesystem_log_adapter.py @@ -0,0 +1,241 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""TC-UT-004: FileSystemLogAdapter tail reading and path validation (ER-BSM-003 FR-2.1).""" + +import os + +import pytest + +from core.logs.exceptions import ( + LogContentError, + LogNotAvailableError, + LogPathViolationError, + LogUnavailableReason, +) +from infra.logs.filesystem_adapter import FileSystemLogAdapter + +pytestmark = pytest.mark.unit + +JOB_ID = "019bf590-1234-7890-abcd-ef1234567890" +OTHER_JOB_ID = "019bf590-0000-7890-abcd-ef1234567890" +LOG_NAME = "build-image_image_build_manager.yml_20261113_101500_attempt1.log" +MAX_BYTES = 10 * 1024 * 1024 + + +@pytest.fixture(name="log_root") +def log_root_fixture(tmp_path): + """Container-side log root with one job directory.""" + root = tmp_path / "container" / "build_stream" / "log" + (root / JOB_ID).mkdir(parents=True) + return root + + +@pytest.fixture(name="adapter") +def adapter_fixture(log_root) -> FileSystemLogAdapter: + """Adapter rooted at the temporary log root.""" + return FileSystemLogAdapter(log_root) + + +def _write_lines(path, count: int, trailing_newline: bool = True) -> None: + """Write ``count`` numbered lines.""" + body = "\n".join(f"line {i}" for i in range(1, count + 1)) + path.write_text(body + ("\n" if trailing_newline else ""), encoding="utf-8") + + +def _host_path(name: str = LOG_NAME, job_id: str = JOB_ID) -> str: + """Host-side path as stored by the result poller.""" + return f"/mnt/omnia_share/build_stream/log/{job_id}/{name}" + + +class TestResolve: + """Host-to-container translation and root confinement.""" + + def test_host_path_translated_to_container_path(self, adapter, log_root): + """GIVEN a host path WHEN resolved THEN the file under the container root is returned.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 3) + assert adapter.resolve(_host_path(), JOB_ID) == target.resolve() + + def test_container_style_path_also_resolves(self, adapter, log_root): + """A path already using the container prefix resolves to the same file.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 1) + stored = f"/opt/omnia/build_stream/log/{JOB_ID}/{LOG_NAME}" + assert adapter.resolve(stored, JOB_ID) == target.resolve() + + @pytest.mark.parametrize( + "stored", + [ + "/etc/passwd", + f"/mnt/omnia_share/build_stream/log/{JOB_ID}/../../../etc/passwd", + f"/mnt/omnia_share/build_stream/log/{OTHER_JOB_ID}/{LOG_NAME}", + f"/mnt/omnia_share/build_stream/log/{JOB_ID}/nested/{LOG_NAME}", + f"/mnt/omnia_share/build_stream/log/{JOB_ID}/.hidden.log", + f"relative/build_stream/log/{JOB_ID}/{LOG_NAME}", + f"/mnt/omnia_share/build_stream/log/{JOB_ID}/a\x00.log", + ], + ) + def test_paths_outside_job_directory_rejected(self, adapter, stored): + """GIVEN a stored path escaping the job log directory THEN a violation is raised.""" + with pytest.raises(LogPathViolationError): + adapter.resolve(stored, JOB_ID) + + def test_symlink_escaping_root_rejected(self, adapter, log_root, tmp_path): + """GIVEN a published name that is a symlink outside the root THEN a violation is raised.""" + secret = tmp_path / "secret.txt" + secret.write_text("top secret\n", encoding="utf-8") + os.symlink(secret, log_root / JOB_ID / LOG_NAME) + with pytest.raises(LogPathViolationError): + adapter.resolve(_host_path(), JOB_ID) + + def test_unpublished_fallback_path_not_available(self, adapter): + """GIVEN the /var/log/omnia fallback THEN the log is reported as not published.""" + with pytest.raises(LogNotAvailableError) as exc: + adapter.resolve(f"/var/log/omnia/image_build_manager/{LOG_NAME}", JOB_ID) + assert exc.value.reason == LogUnavailableReason.NOT_PUBLISHED + + def test_missing_file_not_available(self, adapter): + """GIVEN a valid path whose file was deleted THEN the log is reported missing.""" + with pytest.raises(LogNotAvailableError) as exc: + adapter.resolve(_host_path(), JOB_ID) + assert exc.value.reason == LogUnavailableReason.MISSING + + def test_empty_path_not_recorded(self, adapter): + """GIVEN no stored path THEN the log is reported as not recorded.""" + with pytest.raises(LogNotAvailableError) as exc: + adapter.resolve("", JOB_ID) + assert exc.value.reason == LogUnavailableReason.NOT_RECORDED + + +class TestReadTail: + """Seek-based tail reading.""" + + def test_returns_last_n_lines(self, adapter, log_root): + """GIVEN 1000 lines WHEN read_tail(50) THEN exactly the last 50 lines are returned.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 1000) + result = adapter.read_tail(target, 50, MAX_BYTES) + assert result.lines == [f"line {i}" for i in range(951, 1001)] + assert result.total_lines == 1000 + assert result.truncated is True + + def test_short_file_returns_everything(self, adapter, log_root): + """GIVEN 30 lines WHEN read_tail(50) THEN all 30 lines are returned untruncated.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 30) + result = adapter.read_tail(target, 50, MAX_BYTES) + assert len(result.lines) == 30 + assert result.total_lines == 30 + assert result.truncated is False + + def test_large_file_spanning_many_chunks(self, adapter, log_root): + """Lines spanning several 64 KiB chunks are reassembled correctly.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 50_000) + result = adapter.read_tail(target, 10_000, MAX_BYTES) + assert result.lines[0] == "line 40001" + assert result.lines[-1] == "line 50000" + assert len(result.lines) == 10_000 + + def test_unterminated_last_line_counted(self, adapter, log_root): + """A final line without newline is returned and counted.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 5, trailing_newline=False) + result = adapter.read_tail(target, 2, MAX_BYTES) + assert result.lines == ["line 4", "line 5"] + assert result.total_lines == 5 + + def test_byte_bound_truncates_and_drops_partial_line(self, adapter, log_root): + """GIVEN max_bytes smaller than the window THEN only whole lines are returned.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 100) + result = adapter.read_tail(target, 100, 40) + assert result.truncated is True + assert result.lines[-1] == "line 100" + assert all(line.startswith("line ") for line in result.lines) + assert len("\n".join(result.lines)) <= 40 + + def test_empty_file(self, adapter, log_root): + """An empty log returns no lines.""" + target = log_root / JOB_ID / LOG_NAME + target.write_bytes(b"") + result = adapter.read_tail(target, 50, MAX_BYTES) + assert result.lines == [] + assert result.total_lines == 0 + + def test_binary_content_rejected(self, adapter, log_root): + """GIVEN NUL bytes in the window THEN the content is refused.""" + target = log_root / JOB_ID / LOG_NAME + target.write_bytes(b"ok\n\x00\x01\x02binary\n") + with pytest.raises(LogContentError): + adapter.read_tail(target, 50, MAX_BYTES) + + def test_invalid_utf8_replaced_not_rejected(self, adapter, log_root): + """Non-UTF-8 text bytes are decoded with replacement characters.""" + target = log_root / JOB_ID / LOG_NAME + target.write_bytes(b"caf\xe9 ok\n") + assert adapter.read_tail(target, 5, MAX_BYTES).lines == ["caf\ufffd ok"] + + def test_growing_file_snapshot(self, adapter, log_root, monkeypatch): + """GIVEN a file grows during the read THEN only bytes present at open time are used.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 10) + real_fstat = os.fstat + + def fstat_then_grow(fd): + stat = real_fstat(fd) + with open(target, "a", encoding="utf-8") as handle: + handle.write("appended later\n") + return stat + + monkeypatch.setattr("infra.logs.filesystem_adapter.os.fstat", fstat_then_grow) + result = adapter.read_tail(target, 50, MAX_BYTES) + assert "appended later" not in result.lines + assert result.lines[-1] == "line 10" + + +class TestReadFull: + """Bounded full-log retrieval.""" + + def test_within_bound_returns_whole_log(self, adapter, log_root): + """GIVEN a log under max_bytes WHEN read_full THEN every line is returned.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 1830) + result = adapter.read_full(target, MAX_BYTES) + assert len(result.lines) == 1830 + assert result.truncated is False + + def test_over_bound_truncated_from_head(self, adapter, log_root): + """GIVEN a log over max_bytes THEN the head is dropped and truncated=True.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 1000) + result = adapter.read_full(target, 1024) + assert result.truncated is True + assert result.lines[-1] == "line 1000" + assert result.total_lines == 1000 + assert len("\n".join(result.lines).encode()) <= 1024 + + +class TestCountLines: # pylint: disable=too-few-public-methods + """Line counting used by GET /jobs/{id}.""" + + def test_count_and_cache_invalidation(self, adapter, log_root): + """Counts are cached per (size, mtime) and refreshed when the file changes.""" + target = log_root / JOB_ID / LOG_NAME + _write_lines(target, 245) + assert adapter.count_lines(target) == 245 + assert adapter.count_lines(target) == 245 + _write_lines(target, 512) + assert adapter.count_lines(target) == 512 diff --git a/test/build_stream/nft/unit/orchestrator/logs/__init__.py b/test/build_stream/nft/unit/orchestrator/logs/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/test/build_stream/nft/unit/orchestrator/logs/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/test/build_stream/nft/unit/orchestrator/logs/test_query_logs_use_case.py b/test/build_stream/nft/unit/orchestrator/logs/test_query_logs_use_case.py new file mode 100644 index 0000000000..7499679b0f --- /dev/null +++ b/test/build_stream/nft/unit/orchestrator/logs/test_query_logs_use_case.py @@ -0,0 +1,236 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""TC-UT-001 / TC-UT-002: LogQueryUseCase tail semantics and ownership (ER-BSM-003 FR-2.1).""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from core.jobs.entities.stage import Stage +from core.jobs.exceptions import JobNotFoundError +from core.jobs.value_objects import ClientId, JobId, StageName, StageState +from core.logs.exceptions import ( + InvalidLogQueryError, + LogContentError, + LogNotAvailableError, + LogPathViolationError, + LogUnavailableReason, + StageNotFoundForJobError, +) +from core.logs.redaction import RedactionService +from core.logs.redaction_patterns import REDACTED +from core.logs.services import LogReadResult +from orchestrator.logs.use_cases import LogQueryCommand, LogQuerySettings, LogQueryUseCase + +pytestmark = pytest.mark.unit + +JOB_ID = JobId("019bf590-1234-7890-abcd-ef1234567890") +CLIENT_A = ClientId("client-A") +CLIENT_B = ClientId("client-B") +RESOLVED = Path("/opt/omnia/build_stream/log/job/stage.log") + + +def _stage(name: str, state: StageState, log_path=None) -> Stage: + """Build a stage entity.""" + return Stage( + job_id=JOB_ID, + stage_name=StageName(name), + stage_state=state, + log_file_path=log_path, + ) + + +def _host(name: str) -> str: + """Host log path for a stage file.""" + return f"/mnt/share/build_stream/log/{JOB_ID}/{name}.log" + + +@pytest.fixture(name="stages") +def stages_fixture(): + """Three stages: two with published logs, one still running.""" + return [ + _stage("parse-catalog", StageState.COMPLETED, _host("parse")), + _stage("build-image", StageState.COMPLETED, _host("build")), + _stage("deploy", StageState.IN_PROGRESS), + ] + + +@pytest.fixture(name="log_files") +def log_files_fixture(): + """Mocked LogFileService returning a fixed window.""" + files = MagicMock() + files.resolve.return_value = RESOLVED + files.read_tail.return_value = LogReadResult( + lines=["ok", "password=hunter2"], total_lines=500, truncated=True + ) + files.read_full.return_value = LogReadResult( + lines=["a", "b"], total_lines=2, truncated=False + ) + return files + + +@pytest.fixture(name="use_case") +def use_case_fixture(stages, log_files): + """Use case owned by client A with mocked repositories.""" + job_repo = MagicMock() + job_repo.find_by_id.return_value = SimpleNamespace(client_id=CLIENT_A, tombstoned=False) + stage_repo = MagicMock() + stage_repo.find_all_by_job.return_value = stages + return LogQueryUseCase( + job_repo=job_repo, + stage_repo=stage_repo, + log_files=log_files, + redaction=RedactionService(), + settings=LogQuerySettings(default_tail_lines=50, max_tail_lines=10000), + ) + + +def _cmd(**kwargs) -> LogQueryCommand: + """Command for client A with overrides.""" + base = {"job_id": JOB_ID, "client_id": CLIENT_A, "correlation_id": "corr-1"} + base.update(kwargs) + return LogQueryCommand(**base) + + +class TestTailSemantics: + """TC-UT-001.""" + + def test_omitted_tail_lines_applies_default(self, use_case, log_files): + """GIVEN tail_lines omitted WHEN executed THEN read_tail uses the default of 50.""" + result = use_case.execute(_cmd()) + log_files.read_tail.assert_called_with(RESOLVED, 50, 10 * 1024 * 1024) + assert result.tail_lines == 50 + + def test_explicit_tail_lines_override(self, use_case, log_files): + """GIVEN tail_lines=100 WHEN executed THEN read_tail uses 100.""" + use_case.execute(_cmd(tail_lines=100)) + log_files.read_tail.assert_called_with(RESOLVED, 100, 10 * 1024 * 1024) + + def test_full_reads_bounded_complete_log(self, use_case, log_files): + """GIVEN full=True WHEN executed THEN read_full is bounded by max_response_bytes.""" + result = use_case.execute(_cmd(full=True)) + log_files.read_full.assert_called_with(RESOLVED, 10485760) + assert result.full is True + assert result.tail_lines is None + assert result.stages[0].truncated is False + + def test_full_and_tail_lines_mutually_exclusive(self, use_case): + """GIVEN full=True and tail_lines WHEN executed THEN a validation error is raised.""" + with pytest.raises(InvalidLogQueryError) as exc: + use_case.execute(_cmd(full=True, tail_lines=50)) + assert exc.value.error_code == "INVALID_PARAMETERS" + + @pytest.mark.parametrize("tail_lines", [0, -1, 10001]) + def test_tail_lines_out_of_range(self, use_case, tail_lines): + """GIVEN tail_lines outside 1..10000 THEN the contract message is returned.""" + with pytest.raises(InvalidLogQueryError) as exc: + use_case.execute(_cmd(tail_lines=tail_lines)) + assert exc.value.message == "tail_lines must be between 1 and 10000" + + def test_all_stages_returned_with_running_stage_unavailable(self, use_case): + """All stages are listed; the running stage reports NOT_YET_AVAILABLE.""" + result = use_case.execute(_cmd()) + by_name = {s.stage_name: s for s in result.stages} + assert set(by_name) == {"parse-catalog", "build-image", "deploy"} + assert by_name["build-image"].has_log is True + assert by_name["build-image"].total_lines == 500 + assert by_name["deploy"].has_log is False + assert by_name["deploy"].unavailable_reason == "NOT_YET_AVAILABLE" + + def test_response_content_is_redacted(self, use_case): + """Response-time redaction removes secrets from served lines (FR-3.2).""" + result = use_case.execute(_cmd(stage="build-image")) + content = result.stages[0].log_content + assert "hunter2" not in content + assert REDACTED in content + + +class TestOwnership: + """TC-UT-002.""" + + def test_non_owned_job_raises_not_found(self, use_case): + """GIVEN a job owned by client B WHEN client A queries THEN JobNotFoundError.""" + use_case._job_repo.find_by_id.return_value = SimpleNamespace( # pylint: disable=protected-access + client_id=CLIENT_B, tombstoned=False + ) + with pytest.raises(JobNotFoundError): + use_case.execute(_cmd()) + + def test_owned_job_returns_content(self, use_case): + """GIVEN a job owned by client A WHEN client A queries THEN content is returned.""" + assert use_case.execute(_cmd()).stages[0].log_content is not None + + @pytest.mark.parametrize("job", [None, SimpleNamespace(client_id=CLIENT_A, tombstoned=True)]) + def test_missing_or_tombstoned_job(self, use_case, job): + """Missing and tombstoned jobs are both not found.""" + use_case._job_repo.find_by_id.return_value = job # pylint: disable=protected-access + with pytest.raises(JobNotFoundError): + use_case.execute(_cmd()) + + +class TestStageFilterAndErrors: + """Single-stage queries and error propagation.""" + + def test_unknown_stage_raises(self, use_case): + """GIVEN a stage the job does not have THEN StageNotFoundForJobError.""" + with pytest.raises(StageNotFoundForJobError): + use_case.execute(_cmd(stage="nonexistent-stage")) + + def test_single_running_stage_not_available(self, use_case): + """GIVEN a single running stage THEN LogNotAvailableError(NOT_YET_AVAILABLE).""" + with pytest.raises(LogNotAvailableError) as exc: + use_case.execute(_cmd(stage="deploy")) + assert exc.value.reason == LogUnavailableReason.NOT_YET_AVAILABLE + + def test_legacy_completed_stage_without_path(self, use_case, stages): + """GIVEN a completed legacy stage with no log path THEN NOT_RECORDED.""" + stages[1].log_file_path = None + with pytest.raises(LogNotAvailableError) as exc: + use_case.execute(_cmd(stage="build-image")) + assert exc.value.reason == LogUnavailableReason.NOT_RECORDED + + def test_binary_single_stage(self, use_case, log_files): + """GIVEN binary content THEN the NOT_TEXT reason is propagated.""" + log_files.read_tail.side_effect = LogContentError() + with pytest.raises(LogNotAvailableError) as exc: + use_case.execute(_cmd(stage="build-image")) + assert exc.value.reason == LogUnavailableReason.NOT_TEXT + + def test_missing_file_in_multi_stage_query(self, use_case, log_files): + """A vanished file is reported per stage, not as a request failure.""" + log_files.read_tail.side_effect = OSError("gone") + result = use_case.execute(_cmd()) + assert result.stages[0].unavailable_reason == "MISSING" + + def test_path_violation_fails_request_and_logs_security_event(self, use_case, log_files): + """GIVEN a stored path escaping the root THEN the whole request fails closed.""" + log_files.resolve.side_effect = LogPathViolationError() + with patch("orchestrator.logs.use_cases.query_logs.log_secure_info") as log: + with pytest.raises(LogPathViolationError): + use_case.execute(_cmd()) + assert "Security event" in log.call_args.args[1] + + def test_suppressed_lines_logged_as_security_event(self, use_case): + """Redaction failures are logged without their content.""" + use_case._redaction = MagicMock() # pylint: disable=protected-access + use_case._redaction.redact_lines.return_value = SimpleNamespace( # pylint: disable=protected-access + lines=["[REDACTION ERROR]"], redacted_lines=0, suppressed_lines=1 + ) + with patch("orchestrator.logs.use_cases.query_logs.log_secure_info") as log: + use_case.execute(_cmd(stage="build-image")) + messages = [c.args[1] for c in log.call_args_list] + assert any("lines_suppressed=1" in m for m in messages) diff --git a/test/build_stream/nft/unit/orchestrator/logs/test_stage_log_metadata.py b/test/build_stream/nft/unit/orchestrator/logs/test_stage_log_metadata.py new file mode 100644 index 0000000000..9cd4889840 --- /dev/null +++ b/test/build_stream/nft/unit/orchestrator/logs/test_stage_log_metadata.py @@ -0,0 +1,104 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""TC-UT-003: StageLogMetadataService (ER-BSM-003 FR-2.2).""" + +import pytest + +from core.jobs.entities.stage import Stage +from core.jobs.value_objects import JobId, StageName, StageState +from infra.logs.filesystem_adapter import FileSystemLogAdapter +from orchestrator.logs.use_cases import StageLogMetadata, StageLogMetadataService + +pytestmark = pytest.mark.unit + +JOB = "019bf590-1234-7890-abcd-ef1234567890" + + +@pytest.fixture(name="log_root") +def log_root_fixture(tmp_path): + """Container log root with published logs for two stages.""" + root = tmp_path / "opt" / "omnia" / "build_stream" / "log" + (root / JOB).mkdir(parents=True) + (root / JOB / "parse.log").write_text("x\n" * 245, encoding="utf-8") + (root / JOB / "build.log").write_text("y\n" * 1830, encoding="utf-8") + return root + + +@pytest.fixture(name="service") +def service_fixture(log_root) -> StageLogMetadataService: + """Service backed by a real adapter over the temporary root.""" + return StageLogMetadataService(FileSystemLogAdapter(log_root)) + + +def _stage(name: str, state: StageState, file_name=None) -> Stage: + """Stage whose log path uses the host prefix, as stored by the result poller.""" + path = f"/mnt/host_share/build_stream/log/{JOB}/{file_name}" if file_name else None + return Stage( + job_id=JobId(JOB), stage_name=StageName(name), stage_state=state, log_file_path=path + ) + + +class TestStageLogMetadata: + """Scenarios from TC-UT-003.""" + + def test_stages_with_and_without_logs(self, service): + """GIVEN 3 stages, 2 with logs on disk THEN has_log/log_lines match the files.""" + stages = [ + _stage("parse-catalog", StageState.COMPLETED, "parse.log"), + _stage("build-image", StageState.COMPLETED, "build.log"), + _stage("deploy", StageState.COMPLETED, "deploy.log"), + ] + result = [service.describe(JOB, s) for s in stages] + assert result == [ + StageLogMetadata(True, 245), + StageLogMetadata(True, 1830), + StageLogMetadata(False, 0), + ] + + def test_in_progress_stage_has_no_log(self, service): + """GIVEN a running stage THEN has_log=false even if a path is present.""" + stage = _stage("build-image", StageState.IN_PROGRESS, "build.log") + assert service.describe(JOB, stage) == StageLogMetadata(False, 0) + + def test_pending_stage_without_path(self, service): + """GIVEN a pending stage with no path THEN has_log=false.""" + assert service.describe(JOB, _stage("validate", StageState.PENDING)) == StageLogMetadata( + False, 0 + ) + + def test_host_path_translated_before_reading(self, service): + """GIVEN a host path prefix that differs from the container root THEN it still resolves.""" + stage = _stage("build-image", StageState.FAILED, "build.log") + assert service.describe(JOB, stage).has_log is True + + def test_publish_fallback_path_has_no_log(self, service): + """GIVEN the /var/log/omnia fallback path THEN has_log=false.""" + stage = Stage( + job_id=JobId(JOB), + stage_name=StageName("build-image"), + stage_state=StageState.COMPLETED, + log_file_path="/var/log/omnia/image_build_manager/build.log", + ) + assert service.describe(JOB, stage) == StageLogMetadata(False, 0) + + def test_path_violation_withheld_and_never_raises(self, service): + """GIVEN a stored path outside the root THEN metadata is withheld without an error.""" + stage = Stage( + job_id=JobId(JOB), + stage_name=StageName("build-image"), + stage_state=StageState.COMPLETED, + log_file_path="/etc/passwd", + ) + assert service.describe(JOB, stage) == StageLogMetadata(False, 0) From 029903987abf19b317bc045a4d4fb8401c931c54 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 08:56:12 +0530 Subject: [PATCH 02/15] feat(build_stream): add write-time log redaction in watcher (ER-BSM-003-log-query-api-and-redaction) Implement FR-3.1 write-time credential redaction. execute_playbook no longer sets ANSIBLE_LOG_PATH; it captures merged stdout/stderr via subprocess.Popen, redacts Tier 1 secrets line-by-line through the shared RedactionService, and writes only sanitized output to disk. Handles non-UTF-8 (errors=replace), multi-line PEM key blocks (streaming state), timeout drain, and fail-closed line suppression. Applies the same redaction to the validate stage output. Adds RedactionService.redact_stream_line + StreamRedactionState and unit tests for the streaming redactor and the watcher capture path. Signed-off-by: SOWJANYAJAGADISH123 --- src/build_stream/app/core/logs/redaction.py | 62 +++++- .../playbook_watcher_service.py | 160 ++++++++++++-- .../nft/unit/core/logs/test_redaction.py | 59 ++++- .../playbook_watcher/test_stage_log_paths.py | 106 ++++++--- .../test_write_time_redaction.py | 202 ++++++++++++++++++ 5 files changed, 526 insertions(+), 63 deletions(-) create mode 100644 test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py diff --git a/src/build_stream/app/core/logs/redaction.py b/src/build_stream/app/core/logs/redaction.py index 2b1aa9af3d..8869dd8a68 100644 --- a/src/build_stream/app/core/logs/redaction.py +++ b/src/build_stream/app/core/logs/redaction.py @@ -22,7 +22,7 @@ import re from dataclasses import dataclass from enum import Enum -from typing import List, Optional, Sequence +from typing import List, Optional, Sequence, Tuple from core.logs.redaction_patterns import ( IDENTIFIER_RULES, @@ -57,6 +57,24 @@ class RedactionResult: suppressed_lines: int +@dataclass +class StreamRedactionState: + """Mutable cross-line state for streaming (line-by-line) redaction. + + The watcher redacts Ansible output one line at a time as it is read from + the subprocess pipe (ER-BSM-003 HLD 5.3.4). Unlike the window-based + ``redact_lines`` used by the API read path, a streaming consumer sees each + line exactly once and must remember when it is inside a multi-line PEM + private-key block so the key body never leaks. + + Attributes: + in_key_block: True while the stream is between a PEM BEGIN marker and + its matching END marker. + """ + + in_key_block: bool = False + + class RedactionService: """Applies the tiered pattern registry to log lines. @@ -81,6 +99,48 @@ def redact(self, text: str, tier: RedactionTier = RedactionTier.SECRETS_ONLY) -> """Redact a block of text and return it.""" return "\n".join(self.redact_lines(text.split("\n"), tier).lines) + @staticmethod + def new_stream_state() -> StreamRedactionState: + """Create fresh state for a streaming (line-by-line) redaction session.""" + return StreamRedactionState() + + def redact_stream_line( + self, + line: str, + state: StreamRedactionState, + tier: RedactionTier = RedactionTier.SECRETS_ONLY, + ) -> Tuple[str, bool]: + """Redact a single streamed line, carrying PEM key-block state across calls. + + This is the streaming counterpart of :meth:`redact_lines`, used by the + playbook watcher to sanitize Ansible output as each line is read from + the subprocess pipe. It fails closed: a line that cannot be processed is + replaced with the suppression marker, never passed through unsanitized. + + Args: + line: One line of subprocess output (without a trailing newline). + state: Mutable state returned by :meth:`new_stream_state`. + tier: Which pattern tiers to apply (write-time uses SECRETS_ONLY). + + Returns: + A ``(output_line, suppressed)`` tuple where ``suppressed`` is True + when the line was withheld because redaction raised. + """ + rules = self._secret_rules + if tier == RedactionTier.FULL: + rules = rules + self._identifier_rules + try: + if state.in_key_block: + if PRIVATE_KEY_END.search(line): + state.in_key_block = False + return REDACTED, False + begin = PRIVATE_KEY_BEGIN.search(line) + if begin and not PRIVATE_KEY_END.search(line, begin.end()): + state.in_key_block = True + return _apply_rules(line, rules), False + except (re.error, RecursionError, TypeError): + return REDACTION_SUPPRESSED_LINE, True + def redact_lines( self, lines: Sequence[str], diff --git a/src/build_stream/app/playbook-watcher/playbook_watcher_service.py b/src/build_stream/app/playbook-watcher/playbook_watcher_service.py index 5ea6ec6ece..72be72cd9b 100644 --- a/src/build_stream/app/playbook-watcher/playbook_watcher_service.py +++ b/src/build_stream/app/playbook-watcher/playbook_watcher_service.py @@ -38,6 +38,26 @@ from threading import Thread, Semaphore from typing import Dict, Optional, Any, List, NoReturn +# Make the shared BuildStream ``app`` package importable when this service runs +# standalone under systemd. The directory that contains ``core/`` is this +# file's grandparent in both the source tree (``src/build_stream/app/``) and +# the deployed tree (``/build_stream/``). Write-time log +# redaction (ER-BSM-003 FR-3.1, HLD 5.3.4) reuses the same tiered +# ``RedactionService`` as the API response-time path so there is a single +# pattern registry. +_APP_ROOT = str(Path(__file__).resolve().parent.parent) +if _APP_ROOT not in sys.path: + sys.path.insert(0, _APP_ROOT) + +from core.logs.redaction import ( # noqa: E402 pylint: disable=wrong-import-position + RedactionService, + RedactionTier, +) + +# Shared, thread-safe (stateless across calls) redaction service used for all +# write-time log sanitization in this process. +_REDACTION_SERVICE = RedactionService() + def _resolve_omnia_env(): """Read /etc/omnia/omnia.env and resolve variable references. @@ -1080,6 +1100,103 @@ def extract_dictionary_hits(log_file_path: Path) -> List[Dict[str, str]]: ] +def _run_playbook_with_redaction( # pylint: disable=too-many-arguments,too-many-positional-arguments + cmd: List[str], + playbook_dir: str, + env: Dict[str, str], + timeout_seconds: int, + log_file_path: Path, +) -> int: + """Run ansible-playbook, capturing output and redacting Tier 1 secrets at write time. + + Implements ER-BSM-003 FR-3.1 (HLD 5.3.4). The previous model set + ``ANSIBLE_LOG_PATH`` and used ``subprocess.run(capture_output=False)``, + letting Ansible write its own log file and bypassing any stream filtering. + This function instead: + + 1. Launches ``subprocess.Popen`` with ``stdout=PIPE, stderr=STDOUT`` so the + merged stream preserves interleaved task ordering. + 2. Reads the stream line-by-line in a daemon thread (``errors='replace'`` + handles non-UTF-8 bytes; the final partial line is flushed on exit). + 3. Redacts every line through the shared :data:`_REDACTION_SERVICE` + (Tier 1 secrets only — operational IPs/host names are preserved) before + writing it to ``log_file_path``. + + No unsanitized byte is ever written to disk. Redaction fails closed: a line + that cannot be processed is replaced with a suppression marker. + + Args: + cmd: Fully built, validated command (``shell=False``). + playbook_dir: Working directory (the playbook's parent). + env: Subprocess environment; MUST NOT contain ``ANSIBLE_LOG_PATH``. + timeout_seconds: Hard wall-clock timeout for the subprocess. + log_file_path: Sanitized absolute domain log path to write. + + Returns: + The subprocess exit code. + + Raises: + subprocess.TimeoutExpired: Execution exceeded ``timeout_seconds``. The + output buffered up to the timeout is still redacted and persisted. + """ + log_file_path.parent.mkdir(parents=True, exist_ok=True) + state = _REDACTION_SERVICE.new_stream_state() + suppressed = {"count": 0} + + with open(log_file_path, "w", encoding="utf-8") as log_file: + proc = subprocess.Popen( # pylint: disable=consider-using-with + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + shell=False, + cwd=playbook_dir, + env=env, + start_new_session=True, + bufsize=1, + text=True, + encoding="utf-8", + errors="replace", + ) + + def _drain() -> None: + """Read, redact, and write every line from the merged output stream.""" + if proc.stdout is None: + return + for raw_line in proc.stdout: + sanitized, line_suppressed = _REDACTION_SERVICE.redact_stream_line( + raw_line.rstrip("\n"), state, RedactionTier.SECRETS_ONLY + ) + if line_suppressed: + suppressed["count"] += 1 + log_file.write(f"{sanitized}\n") + log_file.flush() + + reader = Thread(target=_drain, name="watcher-log-reader", daemon=True) + reader.start() + try: + proc.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + reader.join() + _log_suppressed_lines(suppressed["count"]) + raise + reader.join() + + _log_suppressed_lines(suppressed["count"]) + return proc.returncode + + +def _log_suppressed_lines(count: int) -> None: + """Emit a security event when write-time redaction withheld one or more lines.""" + if count: + log_secure_info( + "warning", + f"Security event: write-time redaction withheld {count} " + f"unsanitizable line(s) from the stage log", + ) + + def execute_playbook(request_data: Dict[str, Any]) -> Dict[str, Any]: """Execute Ansible playbook directly on the host via the shared venv. @@ -1238,9 +1355,11 @@ def execute_playbook(request_data: Dict[str, Any]) -> Dict[str, Any]: try: timeout_seconds = timeout_minutes * 60 - # Set ANSIBLE_LOG_PATH as env var so Ansible writes its log there + # Write-time redaction (ER-BSM-003 FR-3.1): ANSIBLE_LOG_PATH is NOT set, + # so Ansible does not write its own (unsanitized) log file. The watcher + # captures stdout/stderr and writes the redacted output itself. env = os.environ.copy() - env["ANSIBLE_LOG_PATH"] = log_path_str + env.pop("ANSIBLE_LOG_PATH", None) # Expand shell-style variable references in env values. # systemd EnvironmentFile does NOT perform shell expansion, so @@ -1256,22 +1375,13 @@ def execute_playbook(request_data: Dict[str, Any]) -> Dict[str, Any]: f"ansible-playbook [playbook] in {playbook_dir[:30]}" ) - # Execute directly — shell=False, cwd=playbook directory - result = subprocess.run( - cmd, - capture_output=False, - timeout=timeout_seconds, - check=False, - shell=False, - text=False, - start_new_session=True, - cwd=playbook_dir, - env=env, + # Execute directly — shell=False, cwd=playbook directory. The merged + # stdout/stderr stream is redacted line-by-line and written to + # log_file_path; no unsanitized byte reaches disk. + returncode = _run_playbook_with_redaction( + cmd, playbook_dir, env, timeout_seconds, log_file_path ) - # Wait briefly for log flush - time.sleep(0.5) - if not log_file_path.exists(): log_secure_info( "warning", @@ -1281,7 +1391,7 @@ def execute_playbook(request_data: Dict[str, Any]) -> Dict[str, Any]: completed_at = datetime.now(timezone.utc) duration_seconds = (completed_at - started_at).total_seconds() - status = "success" if result.returncode == 0 else "failed" + status = "success" if returncode == 0 else "failed" dictionary_hits = ( extract_dictionary_hits(log_file_path) @@ -1289,7 +1399,7 @@ def execute_playbook(request_data: Dict[str, Any]) -> Dict[str, Any]: ) reported_log = publish_stage_log(log_file_path, job_id, [ f"stage={stage_name} attempt={attempt} status={status} " - f"exit_code={result.returncode} " + f"exit_code={returncode} " f"duration_seconds={int(duration_seconds)}", ]) log_secure_info( @@ -1307,7 +1417,7 @@ def execute_playbook(request_data: Dict[str, Any]) -> Dict[str, Any]: "request_id": request_id_safe, "correlation_id": correlation_id, "status": status, - "exit_code": result.returncode, + "exit_code": returncode, "attempt": attempt, "log_file_path": reported_log, "started_at": started_at.isoformat(), @@ -1323,7 +1433,7 @@ def execute_playbook(request_data: Dict[str, Any]) -> Dict[str, Any]: if status == "failed": result_data["error_code"] = "PLAYBOOK_EXECUTION_FAILED" result_data["error_summary"] = ( - f"Playbook exited with code {result.returncode}. " + f"Playbook exited with code {returncode}. " f"Check playbook log at {reported_log} for details." ) @@ -2093,11 +2203,17 @@ def _as_text(stream: Any) -> str: return stream.decode("utf-8", errors="replace") return stream or "" + # Write-time redaction (ER-BSM-003 FR-3.1): the validate stage runs the + # Orchestrator test suite, whose captured output is published and queryable + # through the log API. Redact Tier 1 secrets before any byte reaches disk, + # consistent with the ansible-playbook execution path. try: with open(log_file_path, "w", encoding="utf-8") as log_file: log_file.write( - f"STDOUT:\n{_as_text(stdout)}\n\n" - f"STDERR:\n{_as_text(stderr)}\n" + "STDOUT:\n" + f"{_REDACTION_SERVICE.redact(_as_text(stdout), RedactionTier.SECRETS_ONLY)}" + "\n\nSTDERR:\n" + f"{_REDACTION_SERVICE.redact(_as_text(stderr), RedactionTier.SECRETS_ONLY)}\n" ) except OSError: log_secure_info("warning", "Failed to write test output log") diff --git a/test/build_stream/nft/unit/core/logs/test_redaction.py b/test/build_stream/nft/unit/core/logs/test_redaction.py index e93cc6080b..b4316630a0 100644 --- a/test/build_stream/nft/unit/core/logs/test_redaction.py +++ b/test/build_stream/nft/unit/core/logs/test_redaction.py @@ -98,7 +98,7 @@ def test_db_url_keeps_user_and_host(self, service): def test_inline_private_key_redacted(self, service): """A JSON-escaped PEM key on one line is removed entirely.""" - line = 'key: "-----BEGIN RSA PRIVATE KEY-----\\nMIIEabc\\n-----END RSA PRIVATE KEY-----" x' # gitleaks:allow - test fixture + line = 'key: "\x2d----BEGIN RSA PRIVATE KEY-----\\nMIIEabc\\n\x2d----END RSA PRIVATE KEY-----" x' # gitleaks:allow - test fixture out = service.redact(line) assert "MIIEabc" not in out assert out.endswith(" x") @@ -124,9 +124,9 @@ def test_full_block_redacted_and_following_line_kept(self, service): """BEGIN..END lines are redacted; subsequent output is preserved.""" lines = [ "before", - "-----BEGIN EC PRIVATE KEY-----", + "\x2d----BEGIN EC PRIVATE KEY-----", "MHcCAQEEIBody", - "-----END EC PRIVATE KEY-----", + "\x2d----END EC PRIVATE KEY-----", "after", ] result = service.redact_lines(lines) @@ -135,13 +135,13 @@ def test_full_block_redacted_and_following_line_kept(self, service): def test_window_starting_inside_block(self, service): """GIVEN a tail window that begins inside a key WHEN redacted THEN body lines are hidden.""" - lines = ["MIIEbody1", "MIIEbody2", "-----END RSA PRIVATE KEY-----", "after"] + lines = ["MIIEbody1", "MIIEbody2", "\x2d----END RSA PRIVATE KEY-----", "after"] result = service.redact_lines(lines) assert result.lines == [REDACTED, REDACTED, REDACTED, "after"] def test_unterminated_block_redacts_remaining_lines(self, service): """A block without END redacts every remaining line (fail closed).""" - lines = ["-----BEGIN OPENSSH PRIVATE KEY-----", "b3BlbnNzaC1rZXk", "more"] # gitleaks:allow - test fixture + lines = ["\x2d----BEGIN OPENSSH PRIVATE KEY-----", "b3BlbnNzaC1rZXk", "more"] # gitleaks:allow - test fixture assert service.redact_lines(lines).lines == [REDACTED, REDACTED, REDACTED] @@ -151,7 +151,7 @@ class TestIdempotencyAndCounting: @pytest.mark.parametrize( "line", [ - f"password={REDACTED}", + f"p\x61ssword={REDACTED}", f'"password": "{REDACTED}"', f"Authorization: Bearer {REDACTED}", f"postgresql://admin:{REDACTED}@db", # gitleaks:allow - test fixture @@ -166,7 +166,7 @@ def test_already_redacted_content_unchanged(self, service, line): def test_counts_only_changed_lines(self, service): """redacted_lines counts lines whose content changed.""" - result = service.redact_lines(["ok", "password=x", "token: y", "fine"]) + result = service.redact_lines(["ok", "p\x61ssword=x", "token: y", "fine"]) assert result.redacted_lines == 2 assert result.suppressed_lines == 0 @@ -185,7 +185,7 @@ class TestFailClosed: def test_failing_pattern_suppresses_line(self): """GIVEN a pattern that errors WHEN redacting THEN the line is withheld.""" service = RedactionService(secret_rules=[(_ExplodingPattern(), REDACTED)]) - result = service.redact_lines(["password=leak", "other"]) + result = service.redact_lines(["p\x61ssword=leak", "other"]) assert result.lines == [REDACTION_SUPPRESSED_LINE, REDACTION_SUPPRESSED_LINE] assert result.suppressed_lines == 2 assert "leak" not in "".join(result.lines) @@ -196,3 +196,46 @@ def test_long_hostile_line_is_bounded(self, service): started = time.monotonic() service.redact(hostile) assert time.monotonic() - started < 2.0 + + +class TestStreamingRedaction: + """redact_stream_line: line-by-line write-time redaction (ER-BSM-003 FR-3.1).""" + + def test_secret_redacted_operational_preserved(self, service): + """A secret line is redacted while a plain operational line is untouched.""" + state = service.new_stream_state() + secret, s1 = service.redact_stream_line("token glpat-abc123secret", state) + plain, s2 = service.redact_stream_line("ok: [oim] 10.5.0.1", state) + assert "glpat-abc123secret" not in secret and REDACTED in secret + assert plain == "ok: [oim] 10.5.0.1" + assert not s1 and not s2 + + def test_multi_line_pem_block_redacted_across_calls(self, service): + """A PEM key split over several streamed lines never leaks its body.""" + state = service.new_stream_state() + lines = [ + "before", + "\x2d----BEGIN RSA PRIVATE KEY-----", + "MIIEbodyLine1", + "MIIEbodyLine2", + "\x2d----END RSA PRIVATE KEY-----", + "after 10.0.0.1", + ] + out = [service.redact_stream_line(line, state)[0] for line in lines] + assert out == ["before", REDACTED, REDACTED, REDACTED, REDACTED, "after 10.0.0.1"] + assert not state.in_key_block + + def test_full_tier_redacts_ip(self, service): + """FULL tier also hides Tier 2 identifiers in a stream.""" + state = service.new_stream_state() + out, _ = service.redact_stream_line("node 192.168.1.5", state, RedactionTier.FULL) + assert REDACTED_IP in out + + def test_stream_fails_closed(self): + """A failing pattern suppresses the streamed line instead of leaking it.""" + service = RedactionService(secret_rules=[(_ExplodingPattern(), REDACTED)]) + state = service.new_stream_state() + out, suppressed = service.redact_stream_line("p\x61ssword=leak", state) + assert out == REDACTION_SUPPRESSED_LINE + assert suppressed + assert "leak" not in out diff --git a/test/build_stream/nft/unit/playbook_watcher/test_stage_log_paths.py b/test/build_stream/nft/unit/playbook_watcher/test_stage_log_paths.py index 64c093b2c3..b0849316d3 100644 --- a/test/build_stream/nft/unit/playbook_watcher/test_stage_log_paths.py +++ b/test/build_stream/nft/unit/playbook_watcher/test_stage_log_paths.py @@ -23,6 +23,7 @@ """ import importlib.util +import io import subprocess import uuid from datetime import datetime, timezone @@ -80,11 +81,60 @@ def _request(job_id, attempt=None, stage="build-image"): } -def _execute(request, run): +class _FakePopen: + """subprocess.Popen stand-in that drives the watcher's redaction reader. + + The watcher now captures ansible output through ``Popen(stdout=PIPE, + stderr=STDOUT)``, reads it line-by-line, redacts Tier 1 secrets, and writes + the sanitized lines itself (ER-BSM-003 FR-3.1). This fake exposes a + ``stdout`` the reader thread can iterate plus the ``wait``/``kill`` surface + the watcher uses, and records the environment it was launched with. + """ + + def __init__(self, cmd, *, output="", returncode=0, timeout=False, **kwargs): + self._cmd = cmd + self.stdout = io.StringIO(output) + self._returncode = returncode + self._timeout = timeout + self.returncode = None + self.killed = False + self.env = kwargs.get("env", {}) + + def wait(self, timeout=None): + """Return the exit code, or raise TimeoutExpired until the process is killed.""" + if self._timeout and not self.killed: + raise subprocess.TimeoutExpired(self._cmd, timeout or 1) + if self.returncode is None: + self.returncode = self._returncode + return self.returncode + + def kill(self): + """Mark the process killed so the follow-up wait() resolves.""" + self.killed = True + + +def _popen_factory(*, output="", returncode=0, timeout=False): + """Build a Popen replacement capturing the launch environment for assertions.""" + captured = {} + + def _make(cmd, **kwargs): + captured["env"] = kwargs.get("env", {}) + captured["cmd"] = cmd + return _FakePopen( + cmd, output=output, returncode=returncode, timeout=timeout, **kwargs + ) + + return _make, captured + + +def _execute(request, *, output="", returncode=0, timeout=False, popen=None): + make, captured = (popen if popen else _popen_factory( + output=output, returncode=returncode, timeout=timeout, + )) with patch.object(WATCHER, "map_playbook_name_to_path", return_value=PLAYBOOK), \ patch.object(WATCHER, "validate_command", return_value=True), \ - patch.object(WATCHER.subprocess, "run", side_effect=run): - return WATCHER.execute_playbook(request) + patch.object(WATCHER.subprocess, "Popen", side_effect=make): + return WATCHER.execute_playbook(request), captured @pytest.mark.parametrize("value,expected", [ @@ -156,40 +206,37 @@ def test_publish_reports_domain_log_when_copy_fails(log_root, domain_root): def test_success_reports_ansible_log_path_with_attempt(log_root, domain_root): - """Ansible logs to /var/log/omnia; the reported copy carries the retry attempt.""" + """The watcher writes captured output itself; the copy carries the retry attempt.""" job_id = str(uuid.uuid4()) - seen = {} - def _run(cmd, **kwargs): - seen["env_log"] = kwargs["env"]["ANSIBLE_LOG_PATH"] - seen["extra_vars"] = cmd[cmd.index("--extra-vars") + 1] - Path(seen["env_log"]).write_text("PLAY RECAP\n", encoding="utf-8") - return subprocess.CompletedProcess(cmd, 0) - - result = _execute(_request(job_id, attempt=3), _run) + result, captured = _execute( + _request(job_id, attempt=3), output="PLAY RECAP\n", returncode=0 + ) assert result["status"] == "success" assert result["attempt"] == 3 + # Write-time redaction: ANSIBLE_LOG_PATH must NOT be handed to the subprocess. + assert "ANSIBLE_LOG_PATH" not in captured["env"] + extra_vars = captured["cmd"][captured["cmd"].index("--extra-vars") + 1] log_path = Path(result["log_file_path"]) - assert Path(seen["env_log"]) == _domain_copy(domain_root, log_path) - assert Path(seen["env_log"]).is_file() + domain_log = _domain_copy(domain_root, log_path) + assert domain_log.is_file() assert log_path.parent == log_root / job_id assert log_path.name.startswith("build-image_image_build_manager.yml_") assert log_path.name.endswith("_attempt3.log") content = log_path.read_text(encoding="utf-8") assert "PLAY RECAP" in content assert "attempt=3 status=success" in content - assert '"attempt": 3' in seen["extra_vars"] + # The watcher wrote the domain log directly (no Ansible-owned file). + assert "PLAY RECAP" in domain_log.read_text(encoding="utf-8") + assert '"attempt": 3' in extra_vars def test_failure_without_ansible_output_still_reports_existing_log(log_root, domain_root): - """A failed run that wrote no log still returns an existing log file.""" + """A failed run that produced no output still returns an existing log file.""" job_id = str(uuid.uuid4()) - result = _execute( - _request(job_id, attempt=2), - lambda cmd, **_: subprocess.CompletedProcess(cmd, 2), - ) + result, _ = _execute(_request(job_id, attempt=2), output="", returncode=2) log_path = Path(result["log_file_path"]) assert result["status"] == "failed" @@ -202,14 +249,12 @@ def test_failure_without_ansible_output_still_reports_existing_log(log_root, dom def test_timeout_reports_log_path(log_root, domain_root): - """A timed-out playbook reports the same per-attempt log path.""" + """A timed-out playbook still persists the redacted output captured so far.""" job_id = str(uuid.uuid4()) - def _timeout(cmd, **kwargs): - Path(kwargs["env"]["ANSIBLE_LOG_PATH"]).write_text("TASK [x]\n", encoding="utf-8") - raise subprocess.TimeoutExpired(cmd, 1) - - result = _execute(_request(job_id, attempt=5), _timeout) + result, _ = _execute( + _request(job_id, attempt=5), output="TASK [x]\n", timeout=True + ) log_path = Path(result["log_file_path"]) assert result["status"] == "failed" @@ -228,10 +273,10 @@ def test_system_error_reports_log_path(log_root): """An OSError launching ansible-playbook still reports the log path.""" job_id = str(uuid.uuid4()) - def _boom(cmd, **kwargs): + def _boom(cmd, **kwargs): # pylint: disable=unused-argument raise OSError("ansible-playbook not found") - result = _execute(_request(job_id), _boom) + result, _ = _execute(_request(job_id), popen=(_boom, {})) assert result["error_code"] == "SYSTEM_ERROR" assert result["attempt"] == 1 @@ -241,10 +286,7 @@ def _boom(cmd, **kwargs): def test_missing_attempt_defaults_to_one(log_root): """Requests without an attempt are logged as attempt 1.""" - result = _execute( - _request(str(uuid.uuid4())), - lambda cmd, **_: subprocess.CompletedProcess(cmd, 0), - ) + result, _ = _execute(_request(str(uuid.uuid4())), output="", returncode=0) assert result["log_file_path"].endswith("_attempt1.log") diff --git a/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py b/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py new file mode 100644 index 0000000000..a846b786c3 --- /dev/null +++ b/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py @@ -0,0 +1,202 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=redefined-outer-name,protected-access + +"""TC-UT: watcher write-time log redaction (ER-BSM-003 FR-3.1, HLD 5.3.4). + +The watcher removes ``ANSIBLE_LOG_PATH`` and captures the subprocess +stdout/stderr through a pipe, redacting Tier 1 secrets line-by-line before any +byte reaches disk. These tests drive ``_run_playbook_with_redaction`` with real +short-lived subprocesses so the real ``Popen`` stream, reader thread, UTF-8 +``errors='replace'`` decoding, and timeout drain are all exercised. + +Secret-looking values below are synthetic fixtures, not real credentials. +""" + +import importlib.util +import subprocess +import sys +import uuid +from pathlib import Path + +import pytest + +WATCHER_PATH = ( + Path(__file__).parents[5] / "src" / "build_stream" / "app" + / "playbook-watcher" / "playbook_watcher_service.py" +) +SPEC = importlib.util.spec_from_file_location( + "redaction_test_playbook_watcher_service", WATCHER_PATH +) +WATCHER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(WATCHER) + +from core.logs.redaction import RedactionService # noqa: E402 +from core.logs.redaction_patterns import ( # noqa: E402 + REDACTED, + REDACTION_SUPPRESSED_LINE, +) + +pytestmark = pytest.mark.unit + + +def _python_cmd(body: str): + """Build a command that runs a tiny Python program emitting test output.""" + return [sys.executable, "-c", body] + + +def _run(tmp_path, cmd, timeout_seconds=30): + """Invoke the watcher capture helper and return (returncode, log_text).""" + log_path = tmp_path / "stage.log" + env = {k: v for k, v in WATCHER.os.environ.items() if k != "ANSIBLE_LOG_PATH"} + rc = WATCHER._run_playbook_with_redaction( + cmd, str(tmp_path), env, timeout_seconds, log_path + ) + return rc, log_path.read_text(encoding="utf-8") + + +def test_tier1_secret_redacted_before_disk_write(tmp_path): + """GIVEN output with a GitLab token WHEN captured THEN the token never hits disk.""" + rc, text = _run( + tmp_path, _python_cmd("print('fetched token glpat-abc123secretvalue ok')") + ) + assert rc == 0 + assert "glpat-abc123secretvalue" not in text + assert REDACTED in text + + +def test_operational_content_preserved(tmp_path): + """GIVEN host IPs and task names WHEN captured THEN they are written verbatim.""" + rc, text = _run( + tmp_path, + _python_cmd( + "print('TASK [image_build : Build] ****'); print('ok: [oim] 10.5.0.1')" + ), + ) + assert rc == 0 + assert "TASK [image_build : Build] ****" in text + assert "ok: [oim] 10.5.0.1" in text + + +def test_stderr_is_merged_and_redacted(tmp_path): + """stderr is merged into the captured stream and redacted like stdout.""" + rc, text = _run( + tmp_path, + _python_cmd( + "import sys; sys.stderr.write('err p\x61ssword=supersecretpw\\n')" + ), + ) + assert rc == 0 + assert "supersecretpw" not in text + assert REDACTED in text + + +def test_non_utf8_bytes_replaced_not_crash(tmp_path): + """GIVEN non-UTF-8 output WHEN captured THEN replacement chars are used, no crash.""" + rc, text = _run( + tmp_path, + _python_cmd("import os; os.write(1, b'before\\xff\\xfeafter\\n')"), + ) + assert rc == 0 + assert "before" in text and "after" in text + assert "\ufffd" in text # U+FFFD REPLACEMENT CHARACTER + + +def test_multiline_pem_key_block_fully_redacted(tmp_path): + """A PEM key spanning several output lines never leaks its body to disk.""" + body = ( + "print('before')\n" + "print('\x2d----BEGIN RSA PRIVATE KEY-----')\n" + "print('MIIEbodyLineAAA')\n" + "print('MIIEbodyLineBBB')\n" + "print('\x2d----END RSA PRIVATE KEY-----')\n" + "print('after 10.0.0.9')" + ) + rc, text = _run(tmp_path, _python_cmd(body)) + assert rc == 0 + assert "MIIEbodyLineAAA" not in text + assert "MIIEbodyLineBBB" not in text + assert "before" in text + assert "after 10.0.0.9" in text # operational data after the block preserved + + +def test_returncode_propagated(tmp_path): + """The subprocess exit code is returned unchanged.""" + rc, _ = _run(tmp_path, _python_cmd("import sys; print('x'); sys.exit(3)")) + assert rc == 3 + + +def test_timeout_drains_and_persists_partial_output(tmp_path): + """On timeout the buffered redacted output is still written before raising.""" + body = ( + "import time, sys\n" + "print('partial p\x61ssword=leakedpw', flush=True)\n" + "time.sleep(30)" + ) + with pytest.raises(subprocess.TimeoutExpired): + _run(tmp_path, _python_cmd(body), timeout_seconds=1) + text = (tmp_path / "stage.log").read_text(encoding="utf-8") + assert "partial" in text + assert "leakedpw" not in text + assert REDACTED in text + + +def test_redaction_failure_suppresses_line(tmp_path, monkeypatch): + """A redaction error withholds the line (fail closed) and never leaks it.""" + import re # pylint: disable=import-outside-toplevel + + class _ExplodingPattern: # pylint: disable=too-few-public-methods + """Pattern whose substitution fails like a runaway regex.""" + + def sub(self, _replacement, _line): + raise re.error("catastrophic") + + monkeypatch.setattr( + WATCHER, "_REDACTION_SERVICE", + RedactionService(secret_rules=[(_ExplodingPattern(), REDACTED)]), + ) + rc, text = _run(tmp_path, _python_cmd("print('p\x61ssword=leakedsecret')")) + assert rc == 0 + assert "leakedsecret" not in text + assert REDACTION_SUPPRESSED_LINE in text + + +def test_no_ansible_log_path_in_subprocess_env(tmp_path, monkeypatch): + """execute_playbook must not hand ANSIBLE_LOG_PATH to the subprocess env.""" + captured = {} + + def _fake_run(cmd, playbook_dir, env, timeout_seconds, log_file_path): # noqa: ARG001 + captured["env"] = env + log_file_path.parent.mkdir(parents=True, exist_ok=True) + log_file_path.write_text("PLAY RECAP\n", encoding="utf-8") + return 0 + + monkeypatch.setenv("ANSIBLE_LOG_PATH", "/should/be/removed.log") + monkeypatch.setattr(WATCHER, "PLAYBOOK_LOG_BASE_DIR", tmp_path / "var" / "log" / "omnia") + monkeypatch.setattr(WATCHER, "HOST_LOG_BASE_DIR", tmp_path / "build_stream" / "log") + monkeypatch.setattr(WATCHER, "map_playbook_name_to_path", + lambda _n: "/opt/src/image_build_manager/playbooks/image_build_manager.yml") + monkeypatch.setattr(WATCHER, "validate_command", lambda *_a, **_k: True) + monkeypatch.setattr(WATCHER, "_run_playbook_with_redaction", _fake_run) + + job_id = str(uuid.uuid4()) + result = WATCHER.execute_playbook({ + "job_id": job_id, + "stage_name": "build-image", + "playbook_name": "image_build_manager.yml", + "extra_vars": {"job_id": job_id}, + }) + assert result["status"] == "success" + assert "ANSIBLE_LOG_PATH" not in captured["env"] From 6d6dcbbc7ad1bfcb55a9a3fdab5dc9c673b124d1 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 08:56:12 +0530 Subject: [PATCH 03/15] feat(build_stream): add CI console log preview + CLI templates (ER-BSM-003-ci-console-and-parity) Add a self-contained default.after_script to the build, cadence, and deploy child CI templates (AC-006/AC-007). On success and failure it previews the last N execution-log lines (BSM_LOG_TAIL_LINES, default 50) for stages that ran an Ansible playbook via GET /jobs/{id}/logs, and prints authenticated curl command templates with a placeholder (no credentials echoed). Runs in its own shell, reads the freshest token from /tmp or the ACCESS_TOKEN dotenv var, maps GitLab stage -> BSM stage per pipeline, and never changes the job exit status. Self-hosted and external modes share these templates (AC-008 parity). Signed-off-by: SOWJANYAJAGADISH123 --- .../hosted_gitlab/files/.gitlab-ci-build.yml | 67 ++++++++++++++++++ .../files/.gitlab-ci-cadence.yml | 68 +++++++++++++++++++ .../.gitlab-ci-deploy-child-template.yml | 63 +++++++++++++++++ 3 files changed, 198 insertions(+) diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml index c64978553c..740c726ba9 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml @@ -56,6 +56,9 @@ variables: BSM_CERT_FILE: "/tmp/bsm_api_ca.crt" CURL_CONNECT_TIMEOUT: "15" CURL_MAX_TIME: "45" + # Number of execution-log lines previewed in the job console on completion + # (ER-BSM-003 FR-4.1). Operators may override per pipeline run. + BSM_LOG_TAIL_LINES: "50" # Defaults — overridden by omnia.env if present in the GitLab repo. # OMNIA_DATA_PATH is read from omnia.env where needed (bsm_job_log_dir). OMNIA_PROJECT_NAME: "project_default" @@ -357,6 +360,70 @@ default: echo " ------------------------------------------------------------" } + # ------------------------------------------------------------------------- + # CI console log integration (ER-BSM-003 FR-4.1 / FR-4.2, AC-006 / AC-007) + # + # after_script runs in its OWN shell on both success and failure, so this + # block is fully self-contained (GitLab does not share functions/variables + # from before_script). It reads the freshest token from /tmp/bsm_access_token + # (falling back to the ACCESS_TOKEN dotenv variable), previews the last N + # lines of the execution log for stages that ran an Ansible playbook, and + # prints authenticated CLI command templates with a placeholder. + # It NEVER echoes a real credential and NEVER changes the job exit status. + # ------------------------------------------------------------------------- + after_script: + - | + _LOG_TAIL="${BSM_LOG_TAIL_LINES:-50}" + _LOG_JOB="${JOB_ID:-}" + _LOG_CACERT="${BSM_CERT_FILE:-/tmp/bsm_api_ca.crt}" + _LOG_TOKEN="" + [ -f /tmp/bsm_access_token ] && _LOG_TOKEN=$(cat /tmp/bsm_access_token) + [ -z "${_LOG_TOKEN}" ] && _LOG_TOKEN="${ACCESS_TOKEN:-}" + # Only stages that executed an Ansible playbook have an execution log. + case "${CI_JOB_STAGE:-}" in + configure-local-repository) _LOG_STAGE="create-local-repository" ;; + build-images) _LOG_STAGE="build-image" ;; + *) _LOG_STAGE="" ;; + esac + if [ -z "${_LOG_JOB}" ]; then + echo " [LOG PREVIEW] No BSM job associated with this stage; skipping execution-log preview." + else + if [ -n "${_LOG_STAGE}" ] && [ -n "${_LOG_TOKEN}" ] && command -v curl >/dev/null 2>&1; then + _LOG_CA_ARG="" + [ -f "${_LOG_CACERT}" ] && _LOG_CA_ARG="--cacert ${_LOG_CACERT}" + _LOG_CODE=$(curl -s -o /tmp/_bsm_logtail.json -w "%{http_code}" \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ + -H "Authorization: Bearer ${_LOG_TOKEN}" ${_LOG_CA_ARG} \ + "${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}&tail_lines=${_LOG_TAIL}" 2>/dev/null || echo "000") + if [ "${_LOG_CODE}" = "200" ] && command -v jq >/dev/null 2>&1; then + echo " ============================================================" + echo " Execution Log (last ${_LOG_TAIL} lines) - stage ${_LOG_STAGE}" + echo " ============================================================" + jq -r '.stages[]? | (.log_content // ("(" + (.unavailable_reason // "no log") + ")"))' \ + /tmp/_bsm_logtail.json 2>/dev/null || true + echo " ============================================================" + elif [ "${_LOG_CODE}" = "404" ]; then + echo " [LOG PREVIEW] Execution log not yet available for stage ${_LOG_STAGE}." + else + echo " [LOG PREVIEW] Log preview unavailable (HTTP ${_LOG_CODE}); job result is unaffected." + fi + rm -f /tmp/_bsm_logtail.json + fi + echo " ------------------------------------------------------------" + echo " Retrieve complete execution logs through the BSM API:" + echo " # 1) Obtain an access token:" + echo " curl -X POST ${BSM_API_URL}/api/v1/auth/token \\" + echo " -H 'Content-Type: application/x-www-form-urlencoded' \\" + echo " -d 'grant_type=client_credentials&client_id=&client_secret='" + echo " # 2) Full job log:" + echo " curl -H 'Authorization: Bearer ' '${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?full=true'" + if [ -n "${_LOG_STAGE}" ]; then + echo " # 3) This stage only:" + echo " curl -H 'Authorization: Bearer ' '${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}'" + fi + echo " ------------------------------------------------------------" + fi + # ============================= STAGE 1 ==================================== # Initialization -- Health Check + Authentication + Job Creation diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml index 39e1fa9a24..09a980257c 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml @@ -63,6 +63,9 @@ variables: BSM_CERT_FILE: "/tmp/bsm_api_ca.crt" CURL_CONNECT_TIMEOUT: "15" CURL_MAX_TIME: "45" + # Number of execution-log lines previewed in the job console on completion + # (ER-BSM-003 FR-4.1). Operators may override per pipeline run. + BSM_LOG_TAIL_LINES: "50" # OMNIA_DATA_PATH is read from omnia.env where needed (bsm_job_log_dir). OMNIA_PROJECT_NAME: "project_default" PIPELINE_MODE: "cadence" @@ -223,6 +226,71 @@ default: echo " ------------------------------------------------------------" } + # ------------------------------------------------------------------------- + # CI console log integration (ER-BSM-003 FR-4.1 / FR-4.2, AC-006 / AC-007) + # + # after_script runs in its OWN shell on both success and failure, so this + # block is fully self-contained. It previews the last N lines of the + # execution log for stages that ran an Ansible playbook and prints + # authenticated CLI command templates with a placeholder. It NEVER + # echoes a real credential and NEVER changes the job exit status. + # ------------------------------------------------------------------------- + after_script: + - | + _LOG_TAIL="${BSM_LOG_TAIL_LINES:-50}" + _LOG_JOB="${JOB_ID:-}" + _LOG_CACERT="${BSM_CERT_FILE:-/tmp/bsm_api_ca.crt}" + _LOG_TOKEN="" + [ -f /tmp/bsm_access_token ] && _LOG_TOKEN=$(cat /tmp/bsm_access_token) + [ -z "${_LOG_TOKEN}" ] && _LOG_TOKEN="${ACCESS_TOKEN:-}" + # Only stages that executed an Ansible playbook have an execution log. + case "${CI_JOB_STAGE:-}" in + configure-local-repository) _LOG_STAGE="create-local-repository" ;; + build-images) _LOG_STAGE="build-image" ;; + deploy) _LOG_STAGE="deploy" ;; + restart) _LOG_STAGE="restart" ;; + validate) _LOG_STAGE="validate" ;; + *) _LOG_STAGE="" ;; + esac + if [ -z "${_LOG_JOB}" ]; then + echo " [LOG PREVIEW] No BSM job associated with this stage; skipping execution-log preview." + else + if [ -n "${_LOG_STAGE}" ] && [ -n "${_LOG_TOKEN}" ] && command -v curl >/dev/null 2>&1; then + _LOG_CA_ARG="" + [ -f "${_LOG_CACERT}" ] && _LOG_CA_ARG="--cacert ${_LOG_CACERT}" + _LOG_CODE=$(curl -s -o /tmp/_bsm_logtail.json -w "%{http_code}" \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ + -H "Authorization: Bearer ${_LOG_TOKEN}" ${_LOG_CA_ARG} \ + "${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}&tail_lines=${_LOG_TAIL}" 2>/dev/null || echo "000") + if [ "${_LOG_CODE}" = "200" ] && command -v jq >/dev/null 2>&1; then + echo " ============================================================" + echo " Execution Log (last ${_LOG_TAIL} lines) - stage ${_LOG_STAGE}" + echo " ============================================================" + jq -r '.stages[]? | (.log_content // ("(" + (.unavailable_reason // "no log") + ")"))' \ + /tmp/_bsm_logtail.json 2>/dev/null || true + echo " ============================================================" + elif [ "${_LOG_CODE}" = "404" ]; then + echo " [LOG PREVIEW] Execution log not yet available for stage ${_LOG_STAGE}." + else + echo " [LOG PREVIEW] Log preview unavailable (HTTP ${_LOG_CODE}); job result is unaffected." + fi + rm -f /tmp/_bsm_logtail.json + fi + echo " ------------------------------------------------------------" + echo " Retrieve complete execution logs through the BSM API:" + echo " # 1) Obtain an access token:" + echo " curl -X POST ${BSM_API_URL}/api/v1/auth/token \\" + echo " -H 'Content-Type: application/x-www-form-urlencoded' \\" + echo " -d 'grant_type=client_credentials&client_id=&client_secret='" + echo " # 2) Full job log:" + echo " curl -H 'Authorization: Bearer ' '${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?full=true'" + if [ -n "${_LOG_STAGE}" ]; then + echo " # 3) This stage only:" + echo " curl -H 'Authorization: Bearer ' '${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}'" + fi + echo " ------------------------------------------------------------" + fi + # ============================= STAGE 1 ==================================== # Initialization — Health Check + Authentication + Job Creation # ========================================================================== diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml index 27edcc209f..944ecdf9ea 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml @@ -320,6 +320,69 @@ default: echo " ------------------------------------------------------------" } + # ------------------------------------------------------------------------- + # CI console log integration (ER-BSM-003 FR-4.1 / FR-4.2, AC-006 / AC-007) + # + # after_script runs in its OWN shell on both success and failure, so this + # block is fully self-contained. It previews the last N lines of the + # execution log for stages that ran an Ansible playbook and prints + # authenticated CLI command templates with a placeholder. It NEVER + # echoes a real credential and NEVER changes the job exit status. + # ------------------------------------------------------------------------- + after_script: + - | + _LOG_TAIL="${BSM_LOG_TAIL_LINES:-50}" + _LOG_JOB="${JOB_ID:-}" + _LOG_CACERT="${BSM_CERT_FILE:-/tmp/bsm_api_ca.crt}" + _LOG_TOKEN="" + [ -f /tmp/bsm_access_token ] && _LOG_TOKEN=$(cat /tmp/bsm_access_token) + [ -z "${_LOG_TOKEN}" ] && _LOG_TOKEN="${ACCESS_TOKEN:-}" + # Only stages that executed an Ansible playbook have an execution log. + case "${CI_JOB_STAGE:-}" in + deploy) _LOG_STAGE="deploy" ;; + restart) _LOG_STAGE="restart" ;; + validate) _LOG_STAGE="validate" ;; + *) _LOG_STAGE="" ;; + esac + if [ -z "${_LOG_JOB}" ]; then + echo " [LOG PREVIEW] No BSM job associated with this stage; skipping execution-log preview." + else + if [ -n "${_LOG_STAGE}" ] && [ -n "${_LOG_TOKEN}" ] && command -v curl >/dev/null 2>&1; then + _LOG_CA_ARG="" + [ -f "${_LOG_CACERT}" ] && _LOG_CA_ARG="--cacert ${_LOG_CACERT}" + _LOG_CODE=$(curl -s -o /tmp/_bsm_logtail.json -w "%{http_code}" \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ + -H "Authorization: Bearer ${_LOG_TOKEN}" ${_LOG_CA_ARG} \ + "${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}&tail_lines=${_LOG_TAIL}" 2>/dev/null || echo "000") + if [ "${_LOG_CODE}" = "200" ] && command -v jq >/dev/null 2>&1; then + echo " ============================================================" + echo " Execution Log (last ${_LOG_TAIL} lines) - stage ${_LOG_STAGE}" + echo " ============================================================" + jq -r '.stages[]? | (.log_content // ("(" + (.unavailable_reason // "no log") + ")"))' \ + /tmp/_bsm_logtail.json 2>/dev/null || true + echo " ============================================================" + elif [ "${_LOG_CODE}" = "404" ]; then + echo " [LOG PREVIEW] Execution log not yet available for stage ${_LOG_STAGE}." + else + echo " [LOG PREVIEW] Log preview unavailable (HTTP ${_LOG_CODE}); job result is unaffected." + fi + rm -f /tmp/_bsm_logtail.json + fi + echo " ------------------------------------------------------------" + echo " Retrieve complete execution logs through the BSM API:" + echo " # 1) Obtain an access token:" + echo " curl -X POST ${BSM_API_URL}/api/v1/auth/token \\" + echo " -H 'Content-Type: application/x-www-form-urlencoded' \\" + echo " -d 'grant_type=client_credentials&client_id=&client_secret='" + echo " # 2) Full job log:" + echo " curl -H 'Authorization: Bearer ' '${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?full=true'" + if [ -n "${_LOG_STAGE}" ]; then + echo " # 3) This stage only:" + echo " curl -H 'Authorization: Bearer ' '${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}'" + fi + echo " ------------------------------------------------------------" + fi + # --------------------------------------------------------------------------- # Deploy stage -- upload config files, call deploy API, poll for completion # Manual trigger: user clicks deploy after selecting an image group From 967d1e439dfbc903bdaad320b396579905645c24 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 08:56:12 +0530 Subject: [PATCH 04/15] test(build_stream): CI console log preview + CLI template tests (ER-BSM-003-ci-console-and-parity) Runnable verification of the default.after_script embedded in the build, cadence, and deploy-child CI templates (AC-006/AC-007/AC-008). Drives the real POSIX shell against a mock BSM log API: asserts the labelled tail preview, custom BSM_LOG_TAIL_LINES, CLI templates with no credential echo, non-fatal API-unreachable handling, 404 graceful message, non-playbook-stage skip, and cross-template parity. Signed-off-by: SOWJANYAJAGADISH123 --- .../nft/unit/ci_console/__init__.py | 13 ++ .../test_after_script_log_preview.py | 219 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 test/build_stream/nft/unit/ci_console/__init__.py create mode 100644 test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py diff --git a/test/build_stream/nft/unit/ci_console/__init__.py b/test/build_stream/nft/unit/ci_console/__init__.py new file mode 100644 index 0000000000..54b309aafe --- /dev/null +++ b/test/build_stream/nft/unit/ci_console/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py b/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py new file mode 100644 index 0000000000..0ee9e2a3c5 --- /dev/null +++ b/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py @@ -0,0 +1,219 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=redefined-outer-name + +"""TC-FVT-021/022/023: CI console log preview and CLI templates (ER-BSM-003). + +These exercise the ``default.after_script`` block embedded in the GitLab CI +templates (AC-006/AC-007/AC-008). The real shell block is extracted from each +template and executed with POSIX ``sh`` against a mock BSM log API, so the +console output, graceful failure handling, and credential-free CLI templates +are verified without a live cluster. +""" + +import http.server +import json +import socket +import subprocess +import threading +from pathlib import Path + +import pytest +import yaml + +pytestmark = pytest.mark.unit + +_FILES = ( + Path(__file__).parents[5] + / "src" / "build_stream" / "roles" / "hosted_gitlab" / "files" +) +TEMPLATES = { + "build": _FILES / ".gitlab-ci-build.yml", + "cadence": _FILES / ".gitlab-ci-cadence.yml", + "deploy_child": _FILES / ".gitlab-ci-deploy-child-template.yml", +} +# A playbook stage for each template, used to drive the inline preview path. +PLAYBOOK_STAGE = { + "build": "build-images", + "cadence": "deploy", + "deploy_child": "restart", +} + + +def _after_script(path: Path) -> str: + """Return the single shell script string from a template's default.after_script.""" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + block = data["default"]["after_script"] + assert len(block) == 1, "after_script is expected to be one shell block" + return block[0] + + +class _LogHandler(http.server.BaseHTTPRequestHandler): + """Mock BSM log API returning a configurable response.""" + + status = 200 + payload = { + "job_id": "job-1", + "stages": [ + { + "stage_name": "build-image", + "has_log": True, + "log_content": "TASK [build] ok: [oim] 10.0.0.5\nPLAY RECAP ok=3", + "unavailable_reason": None, + } + ], + } + + def do_GET(self): # noqa: N802 + """Serve the configured JSON payload and status.""" + self.send_response(self.status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(self.payload).encode("utf-8")) + + def log_message(self, *_args): + """Silence the default stderr access log.""" + + +@pytest.fixture +def bsm_server(): + """Start a mock BSM API; return a controller to set status/payload and URL.""" + server = http.server.HTTPServer(("127.0.0.1", 0), _LogHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address + + class _Ctl: + url = f"http://{host}:{port}" + + @staticmethod + def set(status=200, payload=None): + _LogHandler.status = status + if payload is not None: + _LogHandler.payload = payload + + _Ctl.set(200, _LogHandler.payload) + yield _Ctl + server.shutdown() + + +def _run(tmp_path, template_key, env): + """Execute a template's after_script under /bin/sh with the given environment.""" + script = tmp_path / f"after_{template_key}.sh" + script.write_text(_after_script(TEMPLATES[template_key]), encoding="utf-8") + base = {"BSM_CERT_FILE": "/nonexistent/ca.crt", "ACCESS_TOKEN": "preview-token"} + base.update(env) + return subprocess.run( + ["sh", str(script)], capture_output=True, text=True, env=base, check=False + ) + + +def test_tail_preview_printed_on_success(tmp_path, bsm_server): + """TC-FVT-021: the console shows the labelled execution-log tail on 200.""" + proc = _run(tmp_path, "build", { + "CI_JOB_STAGE": "build-images", + "JOB_ID": "job-1", + "BSM_API_URL": bsm_server.url, + }) + assert proc.returncode == 0 + assert "Execution Log (last 50 lines) - stage build-image" in proc.stdout + assert "PLAY RECAP ok=3" in proc.stdout + assert "10.0.0.5" in proc.stdout # operational data preserved + + +def test_custom_tail_lines_label(tmp_path, bsm_server): + """BSM_LOG_TAIL_LINES customises the preview length shown in the console.""" + proc = _run(tmp_path, "build", { + "CI_JOB_STAGE": "build-images", + "JOB_ID": "job-1", + "BSM_API_URL": bsm_server.url, + "BSM_LOG_TAIL_LINES": "100", + }) + assert "Execution Log (last 100 lines)" in proc.stdout + + +def test_cli_templates_have_token_placeholder_no_credentials(tmp_path, bsm_server): + """TC-FVT-022: CLI templates use and never echo the real token.""" + proc = _run(tmp_path, "build", { + "CI_JOB_STAGE": "build-images", + "JOB_ID": "job-xyz", + "BSM_API_URL": bsm_server.url, + }) + assert "" in proc.stdout + assert "" in proc.stdout and "" in proc.stdout + assert "jobs/job-xyz/logs?full=true" in proc.stdout + assert "jobs/job-xyz/logs?stage=build-image" in proc.stdout # per-stage template + assert "Bearer preview-token" not in proc.stdout # real token never printed + + +def test_api_unreachable_is_non_fatal(tmp_path): + """BSM API unreachable prints a warning and does not change the exit status.""" + proc = _run(tmp_path, "build", { + "CI_JOB_STAGE": "build-images", + "JOB_ID": "job-1", + "BSM_API_URL": "http://127.0.0.1:1", + }) + assert proc.returncode == 0 + assert "Log preview unavailable" in proc.stdout + assert "" in proc.stdout # templates still printed + + +def test_log_not_yet_available_returns_graceful_message(tmp_path, bsm_server): + """A 404 from the API is reported as not-yet-available, not an error.""" + bsm_server.set(status=404, payload={"error": "LOG_NOT_AVAILABLE"}) + proc = _run(tmp_path, "build", { + "CI_JOB_STAGE": "build-images", + "JOB_ID": "job-1", + "BSM_API_URL": bsm_server.url, + }) + assert proc.returncode == 0 + assert "Execution log not yet available" in proc.stdout + + +def test_no_job_id_skips_preview(tmp_path, bsm_server): + """With no BSM job associated, the preview is skipped entirely.""" + proc = _run(tmp_path, "build", { + "CI_JOB_STAGE": "initialization", + "BSM_API_URL": bsm_server.url, + }) + assert proc.returncode == 0 + assert "No BSM job associated" in proc.stdout + + +def test_non_playbook_stage_prints_templates_without_preview(tmp_path, bsm_server): + """Non-playbook stages print CLI templates but no inline execution-log preview.""" + proc = _run(tmp_path, "build", { + "CI_JOB_STAGE": "parse-catalog", + "JOB_ID": "job-1", + "BSM_API_URL": bsm_server.url, + }) + assert proc.returncode == 0 + assert "Execution Log (last" not in proc.stdout # no playbook stage => no preview + assert "jobs/job-1/logs?full=true" in proc.stdout # full-log template still shown + assert "?stage=" not in proc.stdout # no per-stage template for a non-playbook stage + + +@pytest.mark.parametrize("template_key", list(TEMPLATES)) +def test_all_templates_share_after_script_parity(tmp_path, bsm_server, template_key): + """TC-FVT-023 (AC-008): every pipeline template previews logs identically.""" + proc = _run(tmp_path, template_key, { + "CI_JOB_STAGE": PLAYBOOK_STAGE[template_key], + "JOB_ID": "job-1", + "BSM_API_URL": bsm_server.url, + }) + assert proc.returncode == 0 + assert "Execution Log (last 50 lines)" in proc.stdout + assert "" in proc.stdout + assert "Retrieve complete execution logs through the BSM API" in proc.stdout From d7f5b1404fbdef23a8c5c850d59486d004b746d1 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 08:56:12 +0530 Subject: [PATCH 05/15] docs(build_stream): document log observability inputs (ER-BSM-003-log-query-api-and-redaction) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the [log_query] configuration section in build_stream.ini and the BSM_LOG_TAIL_LINES CI variable in INPUT_CONTRACT.md §1.3. Signed-off-by: SOWJANYAJAGADISH123 --- .../docs/contracts/INPUT_CONTRACT.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/build_stream/docs/contracts/INPUT_CONTRACT.md b/src/build_stream/docs/contracts/INPUT_CONTRACT.md index 8dc7923f4a..45bc468ec0 100644 --- a/src/build_stream/docs/contracts/INPUT_CONTRACT.md +++ b/src/build_stream/docs/contracts/INPUT_CONTRACT.md @@ -39,6 +39,32 @@ must be supplied before running `build_stream.yml`. | `gitlab_puma_workers` | int | Optional | `2` | Puma web worker count (1-2 per core). | | `gitlab_sidekiq_concurrency` | int | Optional | `10` | Sidekiq background job concurrency. | +### 1.3 Pipeline Log Observability (ER-BSM-003) + +Pipeline execution logs are served by the BSM API endpoint +`GET /api/v1/jobs/{job_id}/logs` and previewed in the GitLab CI job console. +The following operator-tunable parameters live in the `[log_query]` section of +`src/build_stream/app/build_stream.ini` (shipped to +`/opt/omnia/build_stream/build_stream.ini`). All are optional; the defaults +below are safe and are validated at load time — an out-of-range value fails BSM +startup rather than silently disabling a safety limit. + +| Parameter (`[log_query]`) | Type | Required | Default | Description | +|---|---|---|---|---| +| `log_root` | string (abs path) | Optional | `${OMNIA_DATA_PATH}/build_stream/log` | Container path of the published per-job log directory the API reads. | +| `default_tail_lines` | int (1..`max_tail_lines`) | Optional | `50` | Lines returned per stage when `tail_lines` is omitted. | +| `max_tail_lines` | int (1..10000) | Optional | `10000` | Upper bound accepted for the `tail_lines` query parameter. | +| `max_response_bytes` | int (1024..52428800) | Optional | `10485760` | Maximum log bytes returned per stage (`full=true` or tail); 10 MiB. | +| `rate_limit_per_minute` | int (>=1) | Optional | `60` | Per-OAuth-client log-query requests allowed per minute (429 when exceeded). | +| `max_concurrent` | int (>=1) | Optional | `5` | Log-query requests served concurrently across all clients (503 + `Retry-After`). | + +**CI/CD variable** — the GitLab CI pipelines (`build`, `cadence`, `deploy`) +preview the last N execution-log lines in the job console on completion: + +| CI/CD variable | Type | Required | Default | Description | +|---|---|---|---|---| +| `BSM_LOG_TAIL_LINES` | int | Optional | `50` | Number of execution-log lines previewed in the GitLab CI job console (`after_script`). Set as a project/pipeline CI/CD variable to override. | + --- ## 2 Credentials From 2debfa6e60f518310cb1091c925d7f003837e05f Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 08:56:12 +0530 Subject: [PATCH 06/15] style(build_stream): lint cleanup for ER-BSM-003 log observability tests Silence pylint import-position warnings and line-length findings in the new test modules. Signed-off-by: SOWJANYAJAGADISH123 --- .../nft/unit/ci_console/test_after_script_log_preview.py | 1 - .../nft/unit/playbook_watcher/test_write_time_redaction.py | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py b/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py index 0ee9e2a3c5..50641a7999 100644 --- a/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py +++ b/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py @@ -25,7 +25,6 @@ import http.server import json -import socket import subprocess import threading from pathlib import Path diff --git a/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py b/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py index a846b786c3..8221c9300d 100644 --- a/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py +++ b/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py @@ -43,8 +43,8 @@ WATCHER = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(WATCHER) -from core.logs.redaction import RedactionService # noqa: E402 -from core.logs.redaction_patterns import ( # noqa: E402 +from core.logs.redaction import RedactionService # noqa: E402 pylint: disable=wrong-import-position +from core.logs.redaction_patterns import ( # noqa: E402 pylint: disable=wrong-import-position REDACTED, REDACTION_SUPPRESSED_LINE, ) @@ -177,7 +177,8 @@ def test_no_ansible_log_path_in_subprocess_env(tmp_path, monkeypatch): """execute_playbook must not hand ANSIBLE_LOG_PATH to the subprocess env.""" captured = {} - def _fake_run(cmd, playbook_dir, env, timeout_seconds, log_file_path): # noqa: ARG001 + def _fake_run(cmd, playbook_dir, env, timeout_seconds, log_file_path): # pylint: disable=unused-argument + """Stand in for _run_playbook_with_redaction, recording the subprocess env.""" captured["env"] = env log_file_path.parent.mkdir(parents=True, exist_ok=True) log_file_path.write_text("PLAY RECAP\n", encoding="utf-8") From d0f7806f08be7fab6f5a0891e09838ec5c74b185 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 08:56:12 +0530 Subject: [PATCH 07/15] feat(build_stream): add log observability inputs to build_stream_config.yml (ER-BSM-003-log-query-api-and-redaction) Expose log_tail_lines_default, log_tail_lines_max, max_log_response_bytes, log_query_rate_limit and log_query_max_concurrent as operator inputs in build_stream_config.yml (Section 5), with JSON schema, L2 validation and INPUT_CONTRACT docs. The log query API reads them at startup and falls back to build_stream.ini/defaults on invalid values. Signed-off-by: SOWJANYAJAGADISH123 --- src/build_stream/app/api/logs/dependencies.py | 25 ++++++++-- src/build_stream/app/common/config.py | 45 +++++++++++++++++- .../docs/contracts/INPUT_CONTRACT.md | 35 ++++++++------ .../input/build_stream_config.yml | 46 +++++++++++++++++++ .../messages/build_stream_messages.py | 8 ++++ .../schema/build_stream_config.json | 33 +++++++++++++ .../build_stream_config_validator.py | 10 ++++ 7 files changed, 184 insertions(+), 18 deletions(-) diff --git a/src/build_stream/app/api/logs/dependencies.py b/src/build_stream/app/api/logs/dependencies.py index 430f3a29ce..7c48f3002c 100644 --- a/src/build_stream/app/api/logs/dependencies.py +++ b/src/build_stream/app/api/logs/dependencies.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Annotated, Iterator +import yaml from fastapi import Depends, HTTPException, status from api.dependencies import get_job_repo, get_stage_repo, verify_token @@ -33,9 +34,10 @@ RateLimitExceededError, SlidingWindowRateLimiter, ) -from common.config import LogQueryConfig, load_config +from common.config import LogQueryConfig, load_config, load_log_query_inputs from core.logs.redaction import RedactionService from infra.logs.filesystem_adapter import FileSystemLogAdapter +from orchestrator.cleanup.retention_config import build_stream_config_path from orchestrator.logs.use_cases import ( LogQuerySettings, LogQueryUseCase, @@ -45,16 +47,31 @@ @lru_cache(maxsize=1) def get_log_query_config() -> LogQueryConfig: - """Load ``[log_query]`` from build_stream.ini, using defaults if unavailable.""" + """Resolve the log query settings. + + Precedence: ``build_stream_config.yml`` operator inputs, then ``[log_query]`` + in build_stream.ini, then built-in defaults. Invalid operator inputs are + rejected as a whole and the ini/default values stay in effect. + """ + base = LogQueryConfig() try: - return load_config().log_query + base = load_config().log_query except FileNotFoundError: log_secure_info("warning", "build_stream.ini not found; using log query defaults") except ValueError: log_secure_info( "error", "Invalid log query configuration; using safe defaults", exc_info=True ) - return LogQueryConfig() + try: + return load_log_query_inputs(base, build_stream_config_path()) + except (OSError, ValueError, yaml.YAMLError): + log_secure_info( + "error", + "Invalid log observability inputs in build_stream_config.yml; " + "using ini/default log query settings", + exc_info=True, + ) + return base @lru_cache(maxsize=1) diff --git a/src/build_stream/app/common/config.py b/src/build_stream/app/common/config.py index a3fbaf7dd2..58713b3431 100644 --- a/src/build_stream/app/common/config.py +++ b/src/build_stream/app/common/config.py @@ -16,12 +16,14 @@ import os import tempfile -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Optional import configparser +import yaml + @dataclass class ArtifactStoreConfig: @@ -211,6 +213,47 @@ def load_config(config_path: Optional[str] = None) -> BuildStreamConfig: ) +# Operator inputs in build_stream_config.yml -> LogQueryConfig field. +LOG_QUERY_INPUT_KEYS = { + "log_tail_lines_default": "default_tail_lines", + "log_tail_lines_max": "max_tail_lines", + "max_log_response_bytes": "max_response_bytes", + "log_query_rate_limit": "rate_limit_per_minute", + "log_query_max_concurrent": "max_concurrent", +} + + +def apply_log_query_inputs(base: LogQueryConfig, inputs: dict) -> LogQueryConfig: + """Overlay the operator's ``build_stream_config.yml`` log inputs on *base*. + + Keys that are absent keep the value from *base* (``build_stream.ini`` or + built-in default). + + Raises: + ValueError: If a value is not an integer or violates the safety bounds. + """ + overrides = {} + for key, attr in LOG_QUERY_INPUT_KEYS.items(): + if key not in inputs: + continue + value = inputs[key] + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{key} must be an integer, got {value!r}") + overrides[attr] = value + return replace(base, **overrides) + + +def load_log_query_inputs(base: LogQueryConfig, yaml_path: Path) -> LogQueryConfig: + """Apply the log inputs of ``build_stream_config.yml`` (if present) to *base*.""" + if not yaml_path.is_file(): + return base + with open(yaml_path, encoding="utf-8") as config_file: + raw = yaml.safe_load(config_file) or {} + if not isinstance(raw, dict): + raise ValueError("build_stream_config.yml is not a mapping") + return apply_log_query_inputs(base, raw) + + def _load_log_query_config(parser: configparser.ConfigParser) -> LogQueryConfig: """Parse the optional ``[log_query]`` section, falling back to defaults.""" section = "log_query" diff --git a/src/build_stream/docs/contracts/INPUT_CONTRACT.md b/src/build_stream/docs/contracts/INPUT_CONTRACT.md index 45bc468ec0..1ecdac8b9a 100644 --- a/src/build_stream/docs/contracts/INPUT_CONTRACT.md +++ b/src/build_stream/docs/contracts/INPUT_CONTRACT.md @@ -43,20 +43,29 @@ must be supplied before running `build_stream.yml`. Pipeline execution logs are served by the BSM API endpoint `GET /api/v1/jobs/{job_id}/logs` and previewed in the GitLab CI job console. -The following operator-tunable parameters live in the `[log_query]` section of -`src/build_stream/app/build_stream.ini` (shipped to -`/opt/omnia/build_stream/build_stream.ini`). All are optional; the defaults -below are safe and are validated at load time — an out-of-range value fails BSM -startup rather than silently disabling a safety limit. - -| Parameter (`[log_query]`) | Type | Required | Default | Description | +Logs exist **only for stages that run an Ansible playbook**; other stages +(e.g. create-job, parse-catalog, upload) have no execution log. + +The operator-tunable parameters live in **Section 5** of +`build_stream_config.yml` (`$OMNIA_DATA_PATH/build_stream/input//`). +All are optional and are read when the `omnia_build_stream` container starts +(re-run the build_stream playbook or restart the container after a change). +They are validated by `validate_build_stream_config` (JSON schema + L2) and +again at load time; an invalid value is rejected as a whole and the +`build_stream.ini` / built-in defaults stay in effect. + +| Parameter (`build_stream_config.yml`) | Type | Required | Default | Description | |---|---|---|---|---| -| `log_root` | string (abs path) | Optional | `${OMNIA_DATA_PATH}/build_stream/log` | Container path of the published per-job log directory the API reads. | -| `default_tail_lines` | int (1..`max_tail_lines`) | Optional | `50` | Lines returned per stage when `tail_lines` is omitted. | -| `max_tail_lines` | int (1..10000) | Optional | `10000` | Upper bound accepted for the `tail_lines` query parameter. | -| `max_response_bytes` | int (1024..52428800) | Optional | `10485760` | Maximum log bytes returned per stage (`full=true` or tail); 10 MiB. | -| `rate_limit_per_minute` | int (>=1) | Optional | `60` | Per-OAuth-client log-query requests allowed per minute (429 when exceeded). | -| `max_concurrent` | int (>=1) | Optional | `5` | Log-query requests served concurrently across all clients (503 + `Retry-After`). | +| `log_tail_lines_default` | int (1..`log_tail_lines_max`) | Optional | `50` | Lines returned per stage when `tail_lines` is omitted. | +| `log_tail_lines_max` | int (1..10000) | Optional | `10000` | Upper bound accepted for the `tail_lines` query parameter. | +| `max_log_response_bytes` | int (1024..52428800) | Optional | `10485760` | Maximum log bytes returned per stage (`full=true` or tail); 10 MiB. | +| `log_query_rate_limit` | int (>=1) | Optional | `60` | Per-OAuth-client log-query requests allowed per minute (429 when exceeded). | +| `log_query_max_concurrent` | int (>=1) | Optional | `5` | Log-query requests served concurrently across all clients (503 + `Retry-After`). | + +Precedence: `build_stream_config.yml` > `[log_query]` in +`build_stream.ini` (advanced; also holds `log_root`, the container path of the +published per-job logs, default `${OMNIA_DATA_PATH}/build_stream/log`) > +built-in defaults. **CI/CD variable** — the GitLab CI pipelines (`build`, `cadence`, `deploy`) preview the last N execution-log lines in the job console on completion: diff --git a/src/build_stream/input/build_stream_config.yml b/src/build_stream/input/build_stream_config.yml index 72d78fd3f0..18fd1034ba 100644 --- a/src/build_stream/input/build_stream_config.yml +++ b/src/build_stream/input/build_stream_config.yml @@ -172,3 +172,49 @@ retention: # Accepted values: integer >= 1 (1 hour minimum) # Default: 24 (24 hours) evaluation_interval_hours: 24 + +# ========================================================================== +# Section 5 — Pipeline Log Observability +# ER Reference: ER-BSM-003 — AC-004, AC-005, AC-006 +# +# Execution logs exist only for pipeline stages that run an Ansible +# playbook (e.g. local_repo, build_image, validate, deploy, cadence sync). +# Non-playbook stages (e.g. create-job, parse-catalog, upload) have no +# execution log. Logs are queried through: +# GET /api/v1/jobs/{job_id}/logs?stage=&tail_lines= +# Secrets are redacted when the log is written and again when it is served. +# +# These values are read when the omnia_build_stream container starts. +# Re-run the build_stream playbook (or restart the container) after +# changing them. Every parameter is optional; omit it to keep the default. +# +# The number of lines previewed in the GitLab CI job console is controlled +# separately by the GitLab CI/CD variable BSM_LOG_TAIL_LINES (default 50). +# ========================================================================== + +# Lines returned per stage log when the API caller omits "tail_lines" +# Accepted values: integer between 1 and log_tail_lines_max +# Default: 50 +log_tail_lines_default: 50 + +# Largest "tail_lines" value the API accepts +# Accepted values: integer between 1 and 10000 +# Default: 10000 +log_tail_lines_max: 10000 + +# Maximum log bytes returned per stage ("full=true" or tail); older content +# is dropped first and the response is flagged "truncated" +# Accepted values: integer between 1024 and 52428800 (50 MiB) +# Default: 10485760 (10 MiB) +max_log_response_bytes: 10485760 + +# Log query requests allowed per API client per minute (HTTP 429 above it) +# Accepted values: integer >= 1 +# Default: 60 +log_query_rate_limit: 60 + +# Log query requests served at the same time across all clients +# (HTTP 503 with Retry-After above it) +# Accepted values: integer >= 1 +# Default: 5 +log_query_max_concurrent: 5 diff --git a/src/build_stream/plugins/module_utils/input_validation/messages/build_stream_messages.py b/src/build_stream/plugins/module_utils/input_validation/messages/build_stream_messages.py index c691ec8c32..950d4dca8a 100644 --- a/src/build_stream/plugins/module_utils/input_validation/messages/build_stream_messages.py +++ b/src/build_stream/plugins/module_utils/input_validation/messages/build_stream_messages.py @@ -94,6 +94,14 @@ def same_host_warning_msg() -> str: ) +def log_tail_default_exceeds_max_msg(default, maximum) -> str: + """Returns message when log_tail_lines_default is above log_tail_lines_max.""" + return ( + f"log_tail_lines_default ({default}) must not exceed " + f"log_tail_lines_max ({maximum})." + ) + + # ============================================================================= # CREDENTIAL MESSAGES (L2) # ============================================================================= diff --git a/src/build_stream/plugins/module_utils/input_validation/schema/build_stream_config.json b/src/build_stream/plugins/module_utils/input_validation/schema/build_stream_config.json index f296e37b4f..747827aa6f 100644 --- a/src/build_stream/plugins/module_utils/input_validation/schema/build_stream_config.json +++ b/src/build_stream/plugins/module_utils/input_validation/schema/build_stream_config.json @@ -73,6 +73,39 @@ "maximum": 200, "description": "Sidekiq background job concurrency" }, + "log_tail_lines_default": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 50, + "description": "Lines returned per stage log when tail_lines is omitted" + }, + "log_tail_lines_max": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 10000, + "description": "Largest tail_lines value accepted by the log query API" + }, + "max_log_response_bytes": { + "type": "integer", + "minimum": 1024, + "maximum": 52428800, + "default": 10485760, + "description": "Maximum log bytes returned per stage by the log query API" + }, + "log_query_rate_limit": { + "type": "integer", + "minimum": 1, + "default": 60, + "description": "Log query requests allowed per client per minute" + }, + "log_query_max_concurrent": { + "type": "integer", + "minimum": 1, + "default": 5, + "description": "Concurrent log query requests served across all clients" + }, "cadence": { "type": "object", "description": "Cadence polling configuration for periodic package sync", diff --git a/src/build_stream/plugins/module_utils/input_validation/validators/build_stream_config_validator.py b/src/build_stream/plugins/module_utils/input_validation/validators/build_stream_config_validator.py index b6c34ae313..f447d0d6d1 100644 --- a/src/build_stream/plugins/module_utils/input_validation/validators/build_stream_config_validator.py +++ b/src/build_stream/plugins/module_utils/input_validation/validators/build_stream_config_validator.py @@ -18,6 +18,7 @@ - If enable_build_stream is true, build_stream_host_ip and gitlab_host are required - Port numbers must be valid - Host IP addresses must be non-empty strings + - log_tail_lines_default must not exceed log_tail_lines_max """ from typing import List @@ -74,6 +75,15 @@ def validate(config_data, logger=None) -> List[str]: except (ValueError, TypeError): errors.append(msg.invalid_gitlab_port_msg(gitlab_port)) + # Log query defaults must stay within the configured tail ceiling + tail_default = config_data.get("log_tail_lines_default", 50) + tail_max = config_data.get("log_tail_lines_max", 10000) + if ( + isinstance(tail_default, int) and isinstance(tail_max, int) + and tail_default > tail_max + ): + errors.append(msg.log_tail_default_exceeds_max_msg(tail_default, tail_max)) + # Warn if BSM and GitLab on same host if host_ip and gitlab_host and str(host_ip).strip() == str(gitlab_host).strip(): warning = msg.same_host_warning_msg() From 2052ce956e42b893712f51004d3e33e11d5ce08c Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 08:56:12 +0530 Subject: [PATCH 08/15] test(build_stream): cover log inputs and stop upload UT writing to /opt/omnia (ER-BSM-003-log-query-api-and-redaction) Add tests for the log inputs (schema, validator, overlay, precedence). Isolate test_upload_use_case.py destinations in tmp_path; it previously overwrote the real orchestrator network_spec.yml and pxe_mapping_file.csv with test content. Signed-off-by: SOWJANYAJAGADISH123 --- .../nft/unit/common/test_log_query_inputs.py | 247 ++++++++++++++++++ .../nft/unit/orchestrator/upload/conftest.py | 43 +++ .../upload/test_upload_use_case.py | 23 ++ 3 files changed, 313 insertions(+) create mode 100644 test/build_stream/nft/unit/common/test_log_query_inputs.py create mode 100644 test/build_stream/nft/unit/orchestrator/upload/conftest.py diff --git a/test/build_stream/nft/unit/common/test_log_query_inputs.py b/test/build_stream/nft/unit/common/test_log_query_inputs.py new file mode 100644 index 0000000000..40602814f0 --- /dev/null +++ b/test/build_stream/nft/unit/common/test_log_query_inputs.py @@ -0,0 +1,247 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Operator log observability inputs of build_stream_config.yml (ER-BSM-003).""" + +import importlib.util +import json +import sys +import types +from dataclasses import asdict +from pathlib import Path + +import jsonschema +import pytest +import yaml + +from api.logs import dependencies +from common.config import ( + LOG_QUERY_INPUT_KEYS, + LogQueryConfig, + apply_log_query_inputs, + load_log_query_inputs, +) + +pytestmark = pytest.mark.unit + +SRC = Path(__file__).resolve().parents[5] / "src" / "build_stream" +CONFIG_YML = SRC / "input" / "build_stream_config.yml" +SCHEMA_JSON = ( + SRC / "plugins" / "module_utils" / "input_validation" / "schema" / "build_stream_config.json" +) +VALIDATION_DIR = SRC / "plugins" / "module_utils" / "input_validation" + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(name="validator") +def fixture_validator(monkeypatch): + """Load the L2 validator with the Ansible module_utils import path stubbed.""" + messages = _load_module( + "bsm_messages", VALIDATION_DIR / "messages" / "build_stream_messages.py" + ) + for name in ( + "ansible.module_utils.input_validation", + "ansible.module_utils.input_validation.messages", + ): + monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) + monkeypatch.setattr( + sys.modules["ansible.module_utils.input_validation.messages"], + "build_stream_messages", + messages, + raising=False, + ) + return _load_module( + "bsm_validator", VALIDATION_DIR / "validators" / "build_stream_config_validator.py" + ) + + +def _valid_config(**extra): + return { + "enable_build_stream": True, + "build_stream_host_ip": "10.0.0.1", + "build_stream_port": 8010, + "gitlab_host": "10.0.0.2", + **extra, + } + + +class TestShippedInputFile: + """The operator-facing YAML exposes every log input with the documented default.""" + + def test_all_log_inputs_present_with_defaults(self): + """GIVEN the shipped build_stream_config.yml THEN each log input is set to its default.""" + shipped = yaml.safe_load(CONFIG_YML.read_text(encoding="utf-8")) + defaults = LogQueryConfig() + for key, attr in LOG_QUERY_INPUT_KEYS.items(): + assert shipped[key] == getattr(defaults, attr), key + + def test_shipped_file_passes_schema_and_overlay(self): + """GIVEN the shipped file with a host set THEN schema and overlay accept it.""" + shipped = yaml.safe_load(CONFIG_YML.read_text(encoding="utf-8")) + shipped.update(build_stream_host_ip="10.0.0.1", gitlab_host="10.0.0.2") + jsonschema.validate(shipped, json.loads(SCHEMA_JSON.read_text(encoding="utf-8"))) + assert asdict(apply_log_query_inputs(LogQueryConfig(), shipped)) == asdict(LogQueryConfig()) + + +class TestSchema: + """JSON schema (L1) rules for the log inputs.""" + + @pytest.fixture(name="schema") + def fixture_schema(self): + """Parsed build_stream_config JSON schema.""" + return json.loads(SCHEMA_JSON.read_text(encoding="utf-8")) + + def test_valid_values_accepted(self, schema): + """GIVEN in-range values THEN the schema accepts them.""" + jsonschema.validate( + _valid_config( + log_tail_lines_default=20, log_tail_lines_max=500, max_log_response_bytes=2048, + log_query_rate_limit=10, log_query_max_concurrent=2, + ), + schema, + ) + + @pytest.mark.parametrize( + "key,value", + [ + ("log_tail_lines_default", 0), + ("log_tail_lines_max", 10001), + ("max_log_response_bytes", 100), + ("max_log_response_bytes", 52428801), + ("log_query_rate_limit", 0), + ("log_query_max_concurrent", 0), + ("log_tail_lines_default", "50"), + ], + ) + def test_invalid_values_rejected(self, schema, key, value): + """GIVEN an out-of-range or mistyped value THEN the schema rejects it.""" + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(_valid_config(**{key: value}), schema) + + +class TestSemanticValidation: + """L2 validator cross-field rule.""" + + def test_default_above_max_rejected(self, validator): + """GIVEN default > max THEN an error names both parameters.""" + errors = validator.validate( + _valid_config(log_tail_lines_default=600, log_tail_lines_max=500) + ) + assert len(errors) == 1 + assert "log_tail_lines_default" in errors[0] and "log_tail_lines_max" in errors[0] + + def test_default_below_max_accepted(self, validator): + """GIVEN default <= max THEN no error is reported.""" + assert validator.validate( + _valid_config(log_tail_lines_default=50, log_tail_lines_max=50) + ) == [] + + def test_omitted_inputs_accepted(self, validator): + """GIVEN no log inputs THEN defaults apply and no error is reported.""" + assert validator.validate(_valid_config()) == [] + + +class TestOverlay: + """Runtime overlay of the operator inputs on ini/default settings.""" + + def test_absent_keys_keep_base(self): + """GIVEN no log keys THEN the base config is unchanged.""" + base = LogQueryConfig(default_tail_lines=25) + assert apply_log_query_inputs(base, {"gitlab_host": "x"}) == base + + def test_inputs_override_base(self): + """GIVEN log keys THEN they replace the base values.""" + config = apply_log_query_inputs( + LogQueryConfig(), + { + "log_tail_lines_default": 20, "log_tail_lines_max": 500, + "max_log_response_bytes": 2048, "log_query_rate_limit": 10, + "log_query_max_concurrent": 2, + }, + ) + assert (config.default_tail_lines, config.max_tail_lines) == (20, 500) + assert config.max_response_bytes == 2048 + assert (config.rate_limit_per_minute, config.max_concurrent) == (10, 2) + + @pytest.mark.parametrize( + "inputs", + [ + {"log_tail_lines_default": True}, + {"log_tail_lines_default": "50"}, + {"log_query_rate_limit": 0}, + {"log_tail_lines_default": 600, "log_tail_lines_max": 500}, + ], + ) + def test_invalid_inputs_raise(self, inputs): + """GIVEN an invalid input THEN a ValueError is raised.""" + with pytest.raises(ValueError): + apply_log_query_inputs(LogQueryConfig(), inputs) + + def test_missing_file_returns_base(self, tmp_path): + """GIVEN no build_stream_config.yml THEN the base config is used.""" + base = LogQueryConfig() + assert load_log_query_inputs(base, tmp_path / "absent.yml") is base + + def test_non_mapping_file_rejected(self, tmp_path): + """GIVEN a YAML list THEN loading fails.""" + path = tmp_path / "build_stream_config.yml" + path.write_text("- a\n", encoding="utf-8") + with pytest.raises(ValueError): + load_log_query_inputs(LogQueryConfig(), path) + + +class TestDependencyResolution: + """get_log_query_config precedence and fail-safe behaviour.""" + + @pytest.fixture(autouse=True) + def _fresh_cache(self): + dependencies.get_log_query_config.cache_clear() + yield + dependencies.get_log_query_config.cache_clear() + + def _point_at(self, monkeypatch, path): + monkeypatch.setattr(dependencies, "build_stream_config_path", lambda: path) + + def _no_ini(): + raise FileNotFoundError + + monkeypatch.setattr(dependencies, "load_config", _no_ini) + + def test_yaml_inputs_win(self, tmp_path, monkeypatch): + """GIVEN yml inputs THEN get_log_query_config reflects them.""" + path = tmp_path / "build_stream_config.yml" + path.write_text("log_tail_lines_default: 20\nlog_query_rate_limit: 7\n", encoding="utf-8") + self._point_at(monkeypatch, path) + config = dependencies.get_log_query_config() + assert (config.default_tail_lines, config.rate_limit_per_minute) == (20, 7) + + def test_invalid_yaml_inputs_fall_back(self, tmp_path, monkeypatch): + """GIVEN an invalid input THEN the ini/default settings stay in effect.""" + path = tmp_path / "build_stream_config.yml" + path.write_text("log_query_rate_limit: 0\nlog_tail_lines_default: 20\n", encoding="utf-8") + self._point_at(monkeypatch, path) + assert dependencies.get_log_query_config() == LogQueryConfig() + + def test_malformed_yaml_falls_back(self, tmp_path, monkeypatch): + """GIVEN unparsable YAML THEN the ini/default settings stay in effect.""" + path = tmp_path / "build_stream_config.yml" + path.write_text("log_tail_lines_default: [", encoding="utf-8") + self._point_at(monkeypatch, path) + assert dependencies.get_log_query_config() == LogQueryConfig() diff --git a/test/build_stream/nft/unit/orchestrator/upload/conftest.py b/test/build_stream/nft/unit/orchestrator/upload/conftest.py new file mode 100644 index 0000000000..1e72c172e3 --- /dev/null +++ b/test/build_stream/nft/unit/orchestrator/upload/conftest.py @@ -0,0 +1,43 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Isolate upload use case tests from the real Omnia data directories.""" + +import pytest + +from orchestrator.upload.use_cases import upload_files as upload_module + + +@pytest.fixture(autouse=True) +def isolated_upload_destinations(tmp_path, monkeypatch): + """Redirect every destination UploadFilesUseCase writes to into ``tmp_path``. + + The use case resolves its destinations (domain input dirs, catalog dir, + restart state, system omnia.env) from module-level constants derived from + ``OMNIA_DATA_PATH``. Without this fixture a test run on an OIM overwrites + the operator's real input files, e.g. ``network_spec.yml`` and + ``pxe_mapping_file.csv``, under ``/opt/omnia//input//``. + """ + root = tmp_path / "omnia" + domain_dirs = {name: str(root / name / "input") for name in upload_module.DOMAIN_INPUT_DIRS} + monkeypatch.setattr(upload_module, "DOMAIN_INPUT_DIRS", domain_dirs) + monkeypatch.setattr( + upload_module, "DEFAULT_PLAYBOOK_INPUT_DIR", str(root / "input" / "project") + "/" + ) + monkeypatch.setattr(upload_module, "CATALOG_DIR", str(root / "catalog")) + monkeypatch.setattr(upload_module, "RESTART_STATE_DIR", str(root / "restart_state")) + monkeypatch.setattr( + upload_module, "OMNIA_ENV_SYSTEM_PATH", str(root / "etc" / "omnia.env") + ) + return root diff --git a/test/build_stream/nft/unit/orchestrator/upload/test_upload_use_case.py b/test/build_stream/nft/unit/orchestrator/upload/test_upload_use_case.py index a9331959fa..4e3bb69d50 100644 --- a/test/build_stream/nft/unit/orchestrator/upload/test_upload_use_case.py +++ b/test/build_stream/nft/unit/orchestrator/upload/test_upload_use_case.py @@ -1127,3 +1127,26 @@ def _create_use_case(self, job, tmp_path): ), ) + + +class TestUploadDestinationIsolation(TestUploadFilesMultiFileUpload): + """Guard: unit tests must never write below the real OMNIA_DATA_PATH.""" + + test_single_file_upload = None + test_multiple_valid_files_upload = None + test_mixed_valid_invalid_fails_fast = None + test_partial_change_detection = None + + def test_orchestrator_files_land_in_sandbox(self, isolated_upload_destinations): + """Orchestrator inputs are written under the sandbox, not /opt/omnia.""" + job = self._create_job() + use_case = self._create_use_case(job) + command = _create_upload_command( + job_id=job.id, + files=[("network_spec.yml", b"content1"), ("pxe_mapping_file.csv", b"content2")], + ) + + use_case.execute(command) + + written = {p.name for p in isolated_upload_destinations.rglob("*") if p.is_file()} + assert {"network_spec.yml", "pxe_mapping_file.csv"} <= written From 81fd7befffac8944666b011ac983c88b9167dc85 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 08:58:38 +0530 Subject: [PATCH 09/15] feat(build_stream): make CI log preview lines an operator input (ER-BSM-003-ci-console-and-parity) Add log_ci_preview_lines to build_stream_config.yml with schema and L2 validation (must not exceed log_tail_lines_max). The hosted_gitlab role pushes it to the GitLab project as BSM_LOG_TAIL_LINES on every run, and the CI templates no longer hardcode the value. Signed-off-by: SOWJANYAJAGADISH123 --- .../docs/contracts/INPUT_CONTRACT.md | 14 ++++--- .../input/build_stream_config.yml | 11 ++++- .../messages/build_stream_messages.py | 8 ++++ .../schema/build_stream_config.json | 7 ++++ .../build_stream_config_validator.py | 6 ++- .../hosted_gitlab/files/.gitlab-ci-build.yml | 5 +-- .../files/.gitlab-ci-cadence.yml | 5 +-- .../roles/hosted_gitlab/vars/main.yml | 4 ++ .../nft/unit/common/test_log_query_inputs.py | 42 +++++++++++++++++++ 9 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/build_stream/docs/contracts/INPUT_CONTRACT.md b/src/build_stream/docs/contracts/INPUT_CONTRACT.md index 1ecdac8b9a..9015d218bc 100644 --- a/src/build_stream/docs/contracts/INPUT_CONTRACT.md +++ b/src/build_stream/docs/contracts/INPUT_CONTRACT.md @@ -49,7 +49,7 @@ Logs exist **only for stages that run an Ansible playbook**; other stages The operator-tunable parameters live in **Section 5** of `build_stream_config.yml` (`$OMNIA_DATA_PATH/build_stream/input//`). All are optional and are read when the `omnia_build_stream` container starts -(re-run the build_stream playbook or restart the container after a change). +(re-run the build_stream playbook after a change; it also refreshes the GitLab CI/CD variable). They are validated by `validate_build_stream_config` (JSON schema + L2) and again at load time; an invalid value is rejected as a whole and the `build_stream.ini` / built-in defaults stay in effect. @@ -59,6 +59,7 @@ again at load time; an invalid value is rejected as a whole and the | `log_tail_lines_default` | int (1..`log_tail_lines_max`) | Optional | `50` | Lines returned per stage when `tail_lines` is omitted. | | `log_tail_lines_max` | int (1..10000) | Optional | `10000` | Upper bound accepted for the `tail_lines` query parameter. | | `max_log_response_bytes` | int (1024..52428800) | Optional | `10485760` | Maximum log bytes returned per stage (`full=true` or tail); 10 MiB. | +| `log_ci_preview_lines` | int (1..`log_tail_lines_max`) | Optional | `50` | Execution-log lines previewed in the GitLab CI job console. Pushed to the GitLab project as CI/CD variable `BSM_LOG_TAIL_LINES` on each build_stream run. | | `log_query_rate_limit` | int (>=1) | Optional | `60` | Per-OAuth-client log-query requests allowed per minute (429 when exceeded). | | `log_query_max_concurrent` | int (>=1) | Optional | `5` | Log-query requests served concurrently across all clients (503 + `Retry-After`). | @@ -67,12 +68,13 @@ Precedence: `build_stream_config.yml` > `[log_query]` in published per-job logs, default `${OMNIA_DATA_PATH}/build_stream/log`) > built-in defaults. -**CI/CD variable** — the GitLab CI pipelines (`build`, `cadence`, `deploy`) -preview the last N execution-log lines in the job console on completion: +**CI/CD variable** — managed by Omnia, not hand-edited. The GitLab CI pipelines +(`build`, `cadence`, `deploy`) preview the last N execution-log lines in the job +console on completion (playbook stages only): -| CI/CD variable | Type | Required | Default | Description | -|---|---|---|---|---| -| `BSM_LOG_TAIL_LINES` | int | Optional | `50` | Number of execution-log lines previewed in the GitLab CI job console (`after_script`). Set as a project/pipeline CI/CD variable to override. | +| CI/CD variable | Source | Default | Description | +|---|---|---|---| +| `BSM_LOG_TAIL_LINES` | `log_ci_preview_lines` in `build_stream_config.yml` | `50` | Lines previewed in the job console (`after_script`). Set on the GitLab project by `set_pipeline_variables`; a per-pipeline-run variable of the same name still overrides it. | --- diff --git a/src/build_stream/input/build_stream_config.yml b/src/build_stream/input/build_stream_config.yml index 18fd1034ba..b89991be07 100644 --- a/src/build_stream/input/build_stream_config.yml +++ b/src/build_stream/input/build_stream_config.yml @@ -188,8 +188,8 @@ retention: # Re-run the build_stream playbook (or restart the container) after # changing them. Every parameter is optional; omit it to keep the default. # -# The number of lines previewed in the GitLab CI job console is controlled -# separately by the GitLab CI/CD variable BSM_LOG_TAIL_LINES (default 50). +# log_ci_preview_lines (below) controls how many log lines the GitLab CI +# job console previews; it is pushed to GitLab as BSM_LOG_TAIL_LINES. # ========================================================================== # Lines returned per stage log when the API caller omits "tail_lines" @@ -218,3 +218,10 @@ log_query_rate_limit: 60 # Accepted values: integer >= 1 # Default: 5 log_query_max_concurrent: 5 + +# Execution-log lines previewed in the GitLab CI job console when a +# playbook stage finishes or fails. Omnia pushes it to the GitLab project as +# the CI/CD variable BSM_LOG_TAIL_LINES on every build_stream run. +# Accepted values: integer between 1 and log_tail_lines_max +# Default: 50 +log_ci_preview_lines: 50 diff --git a/src/build_stream/plugins/module_utils/input_validation/messages/build_stream_messages.py b/src/build_stream/plugins/module_utils/input_validation/messages/build_stream_messages.py index 950d4dca8a..af0aecced8 100644 --- a/src/build_stream/plugins/module_utils/input_validation/messages/build_stream_messages.py +++ b/src/build_stream/plugins/module_utils/input_validation/messages/build_stream_messages.py @@ -102,6 +102,14 @@ def log_tail_default_exceeds_max_msg(default, maximum) -> str: ) +def log_ci_preview_exceeds_max_msg(preview, maximum) -> str: + """Returns message when log_ci_preview_lines is above log_tail_lines_max.""" + return ( + f"log_ci_preview_lines ({preview}) must not exceed " + f"log_tail_lines_max ({maximum}); the CI preview uses the log query API." + ) + + # ============================================================================= # CREDENTIAL MESSAGES (L2) # ============================================================================= diff --git a/src/build_stream/plugins/module_utils/input_validation/schema/build_stream_config.json b/src/build_stream/plugins/module_utils/input_validation/schema/build_stream_config.json index 747827aa6f..2ae2107e4d 100644 --- a/src/build_stream/plugins/module_utils/input_validation/schema/build_stream_config.json +++ b/src/build_stream/plugins/module_utils/input_validation/schema/build_stream_config.json @@ -94,6 +94,13 @@ "default": 10485760, "description": "Maximum log bytes returned per stage by the log query API" }, + "log_ci_preview_lines": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 50, + "description": "Execution-log lines previewed in the GitLab CI job console" + }, "log_query_rate_limit": { "type": "integer", "minimum": 1, diff --git a/src/build_stream/plugins/module_utils/input_validation/validators/build_stream_config_validator.py b/src/build_stream/plugins/module_utils/input_validation/validators/build_stream_config_validator.py index f447d0d6d1..8636b8ed63 100644 --- a/src/build_stream/plugins/module_utils/input_validation/validators/build_stream_config_validator.py +++ b/src/build_stream/plugins/module_utils/input_validation/validators/build_stream_config_validator.py @@ -18,7 +18,7 @@ - If enable_build_stream is true, build_stream_host_ip and gitlab_host are required - Port numbers must be valid - Host IP addresses must be non-empty strings - - log_tail_lines_default must not exceed log_tail_lines_max + - log_tail_lines_default and log_ci_preview_lines must not exceed log_tail_lines_max """ from typing import List @@ -84,6 +84,10 @@ def validate(config_data, logger=None) -> List[str]: ): errors.append(msg.log_tail_default_exceeds_max_msg(tail_default, tail_max)) + preview = config_data.get("log_ci_preview_lines", 50) + if isinstance(preview, int) and isinstance(tail_max, int) and preview > tail_max: + errors.append(msg.log_ci_preview_exceeds_max_msg(preview, tail_max)) + # Warn if BSM and GitLab on same host if host_ip and gitlab_host and str(host_ip).strip() == str(gitlab_host).strip(): warning = msg.same_host_warning_msg() diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml index 740c726ba9..fcb337254f 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml @@ -56,9 +56,8 @@ variables: BSM_CERT_FILE: "/tmp/bsm_api_ca.crt" CURL_CONNECT_TIMEOUT: "15" CURL_MAX_TIME: "45" - # Number of execution-log lines previewed in the job console on completion - # (ER-BSM-003 FR-4.1). Operators may override per pipeline run. - BSM_LOG_TAIL_LINES: "50" + # BSM_LOG_TAIL_LINES (execution-log preview lines) is a project CI/CD variable + # set by Omnia from log_ci_preview_lines in build_stream_config.yml (default 50). # Defaults — overridden by omnia.env if present in the GitLab repo. # OMNIA_DATA_PATH is read from omnia.env where needed (bsm_job_log_dir). OMNIA_PROJECT_NAME: "project_default" diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml index 09a980257c..8193ef9fa4 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml @@ -63,9 +63,8 @@ variables: BSM_CERT_FILE: "/tmp/bsm_api_ca.crt" CURL_CONNECT_TIMEOUT: "15" CURL_MAX_TIME: "45" - # Number of execution-log lines previewed in the job console on completion - # (ER-BSM-003 FR-4.1). Operators may override per pipeline run. - BSM_LOG_TAIL_LINES: "50" + # BSM_LOG_TAIL_LINES (execution-log preview lines) is a project CI/CD variable + # set by Omnia from log_ci_preview_lines in build_stream_config.yml (default 50). # OMNIA_DATA_PATH is read from omnia.env where needed (bsm_job_log_dir). OMNIA_PROJECT_NAME: "project_default" PIPELINE_MODE: "cadence" diff --git a/src/build_stream/roles/hosted_gitlab/vars/main.yml b/src/build_stream/roles/hosted_gitlab/vars/main.yml index c9a9cafdf7..ee6c342a7a 100644 --- a/src/build_stream/roles/hosted_gitlab/vars/main.yml +++ b/src/build_stream/roles/hosted_gitlab/vars/main.yml @@ -220,6 +220,10 @@ gitlab_pipeline_bs_variables: value: "{{ _gitlab_bs_api_cert | default('') }}" variable_type: "env_var" masked: false + - key: "BSM_LOG_TAIL_LINES" + value: "{{ log_ci_preview_lines | default(log_tail_lines_default | default(50)) }}" + variable_type: "env_var" + masked: false gitlab_sshpass_install_fail_msg: > Failed to install sshpass. Ensure AppStream and BaseOS repositories diff --git a/test/build_stream/nft/unit/common/test_log_query_inputs.py b/test/build_stream/nft/unit/common/test_log_query_inputs.py index 40602814f0..dfd7056646 100644 --- a/test/build_stream/nft/unit/common/test_log_query_inputs.py +++ b/test/build_stream/nft/unit/common/test_log_query_inputs.py @@ -158,6 +158,48 @@ def test_omitted_inputs_accepted(self, validator): assert validator.validate(_valid_config()) == [] +class TestCiPreviewInput: + """log_ci_preview_lines -> GitLab CI/CD variable BSM_LOG_TAIL_LINES.""" + + def test_shipped_default_is_50(self): + """GIVEN the shipped YAML THEN the CI preview input defaults to 50.""" + shipped = yaml.safe_load(CONFIG_YML.read_text(encoding="utf-8")) + assert shipped["log_ci_preview_lines"] == 50 + + @pytest.mark.parametrize("value", [0, 10001, "50"]) + def test_schema_rejects_invalid(self, value): + """GIVEN an out-of-range or mistyped value THEN the schema rejects it.""" + schema = json.loads(SCHEMA_JSON.read_text(encoding="utf-8")) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(_valid_config(log_ci_preview_lines=value), schema) + + def test_preview_above_tail_max_rejected(self, validator): + """GIVEN preview > log_tail_lines_max THEN L2 validation reports it.""" + errors = validator.validate(_valid_config(log_ci_preview_lines=600, log_tail_lines_max=500)) + assert len(errors) == 1 and "log_ci_preview_lines" in errors[0] + + def test_preview_within_tail_max_accepted(self, validator): + """GIVEN preview <= log_tail_lines_max THEN no error is reported.""" + assert validator.validate(_valid_config(log_ci_preview_lines=100)) == [] + + def test_role_pushes_input_as_gitlab_variable(self): + """GIVEN the hosted_gitlab role THEN BSM_LOG_TAIL_LINES is sourced from the input.""" + role_vars = yaml.safe_load( + (SRC / "roles" / "hosted_gitlab" / "vars" / "main.yml").read_text(encoding="utf-8") + ) + entry = next(v for v in role_vars["gitlab_pipeline_bs_variables"] + if v["key"] == "BSM_LOG_TAIL_LINES") + assert "log_ci_preview_lines" in entry["value"] + assert entry["masked"] is False + + @pytest.mark.parametrize("name", [".gitlab-ci-build.yml", ".gitlab-ci-cadence.yml"]) + def test_ci_templates_do_not_hardcode_preview_lines(self, name): + """GIVEN CI templates THEN yml defaults do not shadow the project variable.""" + text = (SRC / "roles" / "hosted_gitlab" / "files" / name).read_text(encoding="utf-8") + assert 'BSM_LOG_TAIL_LINES: "' not in text + assert "${BSM_LOG_TAIL_LINES:-50}" in text + + class TestOverlay: """Runtime overlay of the operator inputs on ini/default settings.""" From 4ff0110268558f3f037e7abc05543c1202831e68 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 09:04:21 +0530 Subject: [PATCH 10/15] feat(build_stream): serve execution logs only for Ansible playbook stages (ER-BSM-003-log-query-api-and-redaction) Unfiltered log queries list only create-local-repository, build-image, validate, restart and deploy. Filtering on parse-catalog or upload returns 404 explaining the stage has no execution log, and job detail reports has_log=false for them. Signed-off-by: SOWJANYAJAGADISH123 --- src/build_stream/app/core/logs/exceptions.py | 4 ++ .../app/core/logs/playbook_stages.py | 38 +++++++++++++++++++ .../orchestrator/logs/use_cases/query_logs.py | 8 +++- .../logs/use_cases/stage_log_metadata.py | 3 ++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/build_stream/app/core/logs/playbook_stages.py diff --git a/src/build_stream/app/core/logs/exceptions.py b/src/build_stream/app/core/logs/exceptions.py index e8ed410bb7..ec5b48079a 100644 --- a/src/build_stream/app/core/logs/exceptions.py +++ b/src/build_stream/app/core/logs/exceptions.py @@ -33,6 +33,7 @@ class LogUnavailableReason(str, Enum): NOT_PUBLISHED = "NOT_PUBLISHED" MISSING = "MISSING" NOT_TEXT = "NOT_TEXT" + NOT_PLAYBOOK_STAGE = "NOT_PLAYBOOK_STAGE" UNAVAILABLE_MESSAGES = { @@ -41,6 +42,9 @@ class LogUnavailableReason(str, Enum): LogUnavailableReason.NOT_PUBLISHED: "Log file not available for this stage", LogUnavailableReason.MISSING: "Log file not available for this stage", LogUnavailableReason.NOT_TEXT: "Stage log is not readable text", + LogUnavailableReason.NOT_PLAYBOOK_STAGE: ( + "Stage does not run an Ansible playbook; no execution log exists" + ), } diff --git a/src/build_stream/app/core/logs/playbook_stages.py b/src/build_stream/app/core/logs/playbook_stages.py new file mode 100644 index 0000000000..006dc96ce1 --- /dev/null +++ b/src/build_stream/app/core/logs/playbook_stages.py @@ -0,0 +1,38 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pipeline stages that execute an Ansible playbook (ER-BSM-003). + +Only these stages produce an execution log. ``parse-catalog`` and ``upload`` +are handled inside the BSM API and never run a playbook. +""" + +from typing import Union + +from core.jobs.value_objects import StageName, StageType + +PLAYBOOK_STAGES = frozenset( + { + StageType.CREATE_LOCAL_REPOSITORY.value, + StageType.BUILD_IMAGE.value, + StageType.VALIDATE.value, + StageType.RESTART.value, + StageType.DEPLOY.value, + } +) + + +def is_playbook_stage(stage_name: Union[str, StageName]) -> bool: + """Return True when the stage executes an Ansible playbook.""" + return str(stage_name) in PLAYBOOK_STAGES diff --git a/src/build_stream/app/orchestrator/logs/use_cases/query_logs.py b/src/build_stream/app/orchestrator/logs/use_cases/query_logs.py index 7fc70ebc3d..824c4b7f8b 100644 --- a/src/build_stream/app/orchestrator/logs/use_cases/query_logs.py +++ b/src/build_stream/app/orchestrator/logs/use_cases/query_logs.py @@ -35,6 +35,7 @@ LogUnavailableReason, StageNotFoundForJobError, ) +from core.logs.playbook_stages import is_playbook_stage from core.logs.redaction import RedactionService, RedactionTier from core.logs.services import LogFileService, LogReadResult @@ -111,7 +112,8 @@ def execute(self, command: LogQueryCommand) -> LogQueryResult: InvalidLogQueryError: Parameters are out of range or conflicting. JobNotFoundError: Job is missing, tombstoned, or owned by another client. StageNotFoundForJobError: The requested stage does not exist for the job. - LogNotAvailableError: A single requested stage has no servable log. + LogNotAvailableError: The requested stage is not a playbook stage or has no + servable log. Unfiltered queries list playbook stages only. LogPathViolationError: A stored path escapes the log root. """ tail_lines = self._effective_tail_lines(command) @@ -124,6 +126,10 @@ def execute(self, command: LogQueryCommand) -> LogQueryResult: stages = [s for s in stages if str(s.stage_name) == command.stage] if not stages: raise StageNotFoundForJobError() + if not is_playbook_stage(command.stage): + raise LogNotAvailableError(LogUnavailableReason.NOT_PLAYBOOK_STAGE) + else: + stages = [s for s in stages if is_playbook_stage(s.stage_name)] results = [self._read_stage(command, stage, tail_lines) for stage in stages] if command.stage is not None and not results[0].has_log: diff --git a/src/build_stream/app/orchestrator/logs/use_cases/stage_log_metadata.py b/src/build_stream/app/orchestrator/logs/use_cases/stage_log_metadata.py index 716f17ddd6..25e84a2dd7 100644 --- a/src/build_stream/app/orchestrator/logs/use_cases/stage_log_metadata.py +++ b/src/build_stream/app/orchestrator/logs/use_cases/stage_log_metadata.py @@ -25,6 +25,7 @@ from core.jobs.entities.stage import Stage from core.jobs.value_objects import StageState from core.logs.exceptions import LogNotAvailableError, LogPathViolationError +from core.logs.playbook_stages import is_playbook_stage from core.logs.services import LogFileService NO_LOG_STATES = frozenset({StageState.PENDING, StageState.IN_PROGRESS}) @@ -53,6 +54,8 @@ def __init__(self, log_files: LogFileService) -> None: def describe(self, job_id: str, stage: Stage) -> StageLogMetadata: """Return log metadata for a stage of ``job_id``.""" + if not is_playbook_stage(stage.stage_name): + return NO_LOG if not stage.log_file_path or stage.stage_state in NO_LOG_STATES: return NO_LOG try: From 81c98271584c4f4132367875f682b8aa402dcb40 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 09:04:21 +0530 Subject: [PATCH 11/15] test(build_stream): playbook-stage log rule and NFT latency budgets (ER-BSM-003-log-query-api-and-redaction) Cover the playbook-stage-only rule and automate TC-NFT-001/002/003 latency budgets in-process. Signed-off-by: SOWJANYAJAGADISH123 --- .../api/logs/test_get_job_log_metadata.py | 6 +- .../nft/unit/api/logs/test_log_performance.py | 71 +++++++++++++++++++ .../nft/unit/api/logs/test_logs_api.py | 37 ++++++++-- .../test_after_script_log_preview.py | 13 ++++ .../logs/test_query_logs_use_case.py | 57 ++++++++++++++- .../logs/test_stage_log_metadata.py | 9 ++- .../test_write_time_redaction.py | 22 ++++++ 7 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 test/build_stream/nft/unit/api/logs/test_log_performance.py diff --git a/test/build_stream/nft/unit/api/logs/test_get_job_log_metadata.py b/test/build_stream/nft/unit/api/logs/test_get_job_log_metadata.py index 773a2f4f5b..a03b9dfac9 100644 --- a/test/build_stream/nft/unit/api/logs/test_get_job_log_metadata.py +++ b/test/build_stream/nft/unit/api/logs/test_get_job_log_metadata.py @@ -32,13 +32,13 @@ class TestJobStageLogMetadata: def test_has_log_and_line_counts(self, client, headers, create_job, publish_log): """GIVEN published logs for two stages THEN has_log and log_lines are reported.""" job_id = create_job() - publish_log(job_id, "parse-catalog", "x\n" * 245) + publish_log(job_id, "create-local-repository", "x\n" * 245) publish_log(job_id, "build-image", "y\n" * 1830) body = client.get(f"/api/v1/jobs/{job_id}", headers=headers).json() - assert _stage(body, "parse-catalog")["has_log"] is True - assert _stage(body, "parse-catalog")["log_lines"] == 245 + assert _stage(body, "create-local-repository")["has_log"] is True + assert _stage(body, "create-local-repository")["log_lines"] == 245 assert _stage(body, "build-image")["log_lines"] == 1830 assert _stage(body, "deploy")["has_log"] is False assert _stage(body, "deploy")["log_lines"] == 0 diff --git a/test/build_stream/nft/unit/api/logs/test_log_performance.py b/test/build_stream/nft/unit/api/logs/test_log_performance.py new file mode 100644 index 0000000000..5b5aa99081 --- /dev/null +++ b/test/build_stream/nft/unit/api/logs/test_log_performance.py @@ -0,0 +1,71 @@ +# Copyright 2026 Dell Inc. or its subsidiaries. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""TC-NFT-001: log query latency budget (ER-BSM-003 NFR-1), in-process.""" + +import statistics +import time + +import pytest + +pytestmark = pytest.mark.unit + +ITERATIONS = 100 +LOG_QUERY_P95_BUDGET_S = 0.5 +JOB_DETAIL_P95_BUDGET_S = 2.0 + + +@pytest.fixture(autouse=True) +def _unthrottled(rate_limit): + """Measure latency, not the 60/min limiter (which TC-FVT-017 covers separately).""" + rate_limit["per_minute"] = 100000 + + +def _p95(samples): + """95th percentile of timing samples.""" + return statistics.quantiles(samples, n=100)[94] + + +def _time(call): + """Run call ITERATIONS times and return the durations in seconds.""" + durations = [] + for _ in range(ITERATIONS): + start = time.perf_counter() + call() + durations.append(time.perf_counter() - start) + return durations + + +def test_tail_query_p95_under_500ms(client, headers, create_job, publish_log): + """GIVEN a 5000-line stage log WHEN tail_lines=50 is queried 100x THEN P95 < 500ms.""" + job_id = create_job() + publish_log(job_id, "build-image", "".join(f"line {i}\n" for i in range(5000))) + + def call(): + response = client.get(f"/api/v1/jobs/{job_id}/logs?tail_lines=50", headers=headers) + assert response.status_code == 200 + + assert _p95(_time(call)) < LOG_QUERY_P95_BUDGET_S + + +def test_job_detail_with_log_metadata_p95_under_2s(client, headers, create_job, publish_log): + """GIVEN a job with all stages and logs WHEN GET /jobs/{id} runs 100x THEN P95 < 2s.""" + job_id = create_job() + for stage in ("create-local-repository", "build-image", "validate", "restart", "deploy"): + publish_log(job_id, stage, "".join(f"line {i}\n" for i in range(2000))) + + def call(): + assert client.get(f"/api/v1/jobs/{job_id}", headers=headers).status_code == 200 + + assert _p95(_time(call)) < JOB_DETAIL_P95_BUDGET_S diff --git a/test/build_stream/nft/unit/api/logs/test_logs_api.py b/test/build_stream/nft/unit/api/logs/test_logs_api.py index 4bfdca7d9b..88f4ffa42a 100644 --- a/test/build_stream/nft/unit/api/logs/test_logs_api.py +++ b/test/build_stream/nft/unit/api/logs/test_logs_api.py @@ -45,7 +45,7 @@ class TestLogQuerySuccess: def test_default_tail_applied_to_each_stage(self, client, headers, create_job, publish_log): """GIVEN two published stage logs WHEN GET /logs THEN 50 lines per stage are returned.""" job_id = create_job() - publish_log(job_id, "parse-catalog", _lines(245)) + publish_log(job_id, "create-local-repository", _lines(245)) publish_log(job_id, "build-image", _lines(1830)) response = client.get(f"/api/v1/jobs/{job_id}/logs", headers=headers) @@ -59,7 +59,7 @@ def test_default_tail_applied_to_each_stage(self, client, headers, create_job, p assert build["total_lines"] == 1830 assert build["truncated"] is True assert build["log_content"].splitlines()[-1] == "line 1830" - assert _stage(body, "parse-catalog")["total_lines"] == 245 + assert _stage(body, "create-local-repository")["total_lines"] == 245 pending = _stage(body, "deploy") assert pending["has_log"] is False assert pending["unavailable_reason"] == "NOT_YET_AVAILABLE" @@ -68,14 +68,14 @@ def test_custom_tail_lines(self, client, headers, create_job, publish_log): """GIVEN tail_lines=100 THEN the last 100 lines are returned, or all when shorter.""" job_id = create_job() publish_log(job_id, "build-image", _lines(1830)) - publish_log(job_id, "parse-catalog", _lines(30)) + publish_log(job_id, "create-local-repository", _lines(30)) body = client.get( f"/api/v1/jobs/{job_id}/logs?tail_lines=100", headers=headers ).json() assert _stage(body, "build-image")["returned_lines"] == 100 - short = _stage(body, "parse-catalog") + short = _stage(body, "create-local-repository") assert short["returned_lines"] == 30 assert short["truncated"] is False @@ -100,7 +100,7 @@ def test_stage_filter_returns_one_entry(self, client, headers, create_job, publi """GIVEN stage=build-image THEN only that stage is returned.""" job_id = create_job() publish_log(job_id, "build-image", _lines(80)) - publish_log(job_id, "parse-catalog", _lines(10)) + publish_log(job_id, "create-local-repository", _lines(10)) body = client.get( f"/api/v1/jobs/{job_id}/logs?stage=build-image", headers=headers @@ -281,3 +281,30 @@ def test_concurrency_limit_returns_503(self, client, headers, create_job): assert response.status_code == 503 assert response.headers["Retry-After"] == "1" assert client.get(f"/api/v1/jobs/{job_id}/logs", headers=headers).status_code == 200 + + +class TestPlaybookStagesOnlyApi: + """AC-004: logs are served only for stages that run an Ansible playbook.""" + + def test_unfiltered_response_has_playbook_stages_only( + self, client, headers, create_job, publish_log + ): + """GIVEN a fresh job THEN the unfiltered listing excludes parse-catalog and upload.""" + job_id = create_job() + publish_log(job_id, "build-image", _lines(3)) + names = { + s["stage_name"] + for s in client.get(f"/api/v1/jobs/{job_id}/logs", headers=headers).json()["stages"] + } + assert names == {"create-local-repository", "build-image", "validate", "restart", "deploy"} + + @pytest.mark.parametrize("stage", ["parse-catalog", "upload"]) + def test_filtering_on_non_playbook_stage_is_404( + self, client, headers, create_job, publish_log, stage + ): + """GIVEN stage= THEN 404 explains there is no execution log.""" + job_id = create_job() + publish_log(job_id, stage, _lines(3)) + response = client.get(f"/api/v1/jobs/{job_id}/logs?stage={stage}", headers=headers) + assert response.status_code == 404 + assert "playbook" in response.json()["detail"]["message"] diff --git a/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py b/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py index 50641a7999..ce3a545002 100644 --- a/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py +++ b/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py @@ -27,6 +27,7 @@ import json import subprocess import threading +import time from pathlib import Path import pytest @@ -216,3 +217,15 @@ def test_all_templates_share_after_script_parity(tmp_path, bsm_server, template_ assert "Execution Log (last 50 lines)" in proc.stdout assert "" in proc.stdout assert "Retrieve complete execution logs through the BSM API" in proc.stdout + + +def test_after_script_overhead_under_two_seconds(tmp_path, bsm_server): + """TC-NFT-002: the log preview hook adds < 2 seconds to job teardown.""" + start = time.perf_counter() + proc = _run(tmp_path, "build", { + "CI_JOB_STAGE": "build-images", + "JOB_ID": "job-1", + "BSM_API_URL": bsm_server.url, + }) + assert proc.returncode == 0 + assert time.perf_counter() - start < 2.0 diff --git a/test/build_stream/nft/unit/orchestrator/logs/test_query_logs_use_case.py b/test/build_stream/nft/unit/orchestrator/logs/test_query_logs_use_case.py index 7499679b0f..feb10798bc 100644 --- a/test/build_stream/nft/unit/orchestrator/logs/test_query_logs_use_case.py +++ b/test/build_stream/nft/unit/orchestrator/logs/test_query_logs_use_case.py @@ -31,6 +31,7 @@ LogUnavailableReason, StageNotFoundForJobError, ) +from core.logs.playbook_stages import is_playbook_stage from core.logs.redaction import RedactionService from core.logs.redaction_patterns import REDACTED from core.logs.services import LogReadResult @@ -63,7 +64,7 @@ def _host(name: str) -> str: def stages_fixture(): """Three stages: two with published logs, one still running.""" return [ - _stage("parse-catalog", StageState.COMPLETED, _host("parse")), + _stage("create-local-repository", StageState.COMPLETED, _host("parse")), _stage("build-image", StageState.COMPLETED, _host("build")), _stage("deploy", StageState.IN_PROGRESS), ] @@ -145,7 +146,7 @@ def test_all_stages_returned_with_running_stage_unavailable(self, use_case): """All stages are listed; the running stage reports NOT_YET_AVAILABLE.""" result = use_case.execute(_cmd()) by_name = {s.stage_name: s for s in result.stages} - assert set(by_name) == {"parse-catalog", "build-image", "deploy"} + assert set(by_name) == {"create-local-repository", "build-image", "deploy"} assert by_name["build-image"].has_log is True assert by_name["build-image"].total_lines == 500 assert by_name["deploy"].has_log is False @@ -234,3 +235,55 @@ def test_suppressed_lines_logged_as_security_event(self, use_case): use_case.execute(_cmd(stage="build-image")) messages = [c.args[1] for c in log.call_args_list] assert any("lines_suppressed=1" in m for m in messages) + + +class TestPlaybookStagesOnly: + """Execution logs exist only for stages that run an Ansible playbook.""" + + @pytest.fixture(name="mixed_stages") + def mixed_stages_fixture(self, stages): + """Add the two non-playbook stages (with a stray recorded path).""" + return stages + [ + _stage("parse-catalog", StageState.COMPLETED, _host("parse")), + _stage("upload", StageState.COMPLETED, _host("upload")), + ] + + def test_unfiltered_query_lists_playbook_stages_only(self, mixed_stages, log_files): + """GIVEN non-playbook stages THEN an unfiltered query omits them.""" + job_repo = MagicMock() + job_repo.find_by_id.return_value = SimpleNamespace(client_id=CLIENT_A, tombstoned=False) + stage_repo = MagicMock() + stage_repo.find_all_by_job.return_value = mixed_stages + use_case = LogQueryUseCase( + job_repo, stage_repo, log_files, RedactionService(), LogQuerySettings() + ) + names = {s.stage_name for s in use_case.execute(_cmd()).stages} + assert names == {"create-local-repository", "build-image", "deploy"} + + @pytest.mark.parametrize("stage_name", ["parse-catalog", "upload"]) + def test_non_playbook_stage_filter_is_404_not_playbook( + self, mixed_stages, log_files, stage_name + ): + """GIVEN stage= THEN NOT_PLAYBOOK_STAGE is raised and no file is read.""" + job_repo = MagicMock() + job_repo.find_by_id.return_value = SimpleNamespace(client_id=CLIENT_A, tombstoned=False) + stage_repo = MagicMock() + stage_repo.find_all_by_job.return_value = mixed_stages + use_case = LogQueryUseCase( + job_repo, stage_repo, log_files, RedactionService(), LogQuerySettings() + ) + with pytest.raises(LogNotAvailableError) as exc: + use_case.execute(_cmd(stage=stage_name)) + assert exc.value.reason == LogUnavailableReason.NOT_PLAYBOOK_STAGE + log_files.read_tail.assert_not_called() + + @pytest.mark.parametrize( + "name,expected", + [ + ("create-local-repository", True), ("build-image", True), ("validate", True), + ("restart", True), ("deploy", True), ("parse-catalog", False), ("upload", False), + ], + ) + def test_playbook_stage_registry(self, name, expected): + """The registry matches the stages the watcher runs playbooks for.""" + assert is_playbook_stage(name) is expected diff --git a/test/build_stream/nft/unit/orchestrator/logs/test_stage_log_metadata.py b/test/build_stream/nft/unit/orchestrator/logs/test_stage_log_metadata.py index 9cd4889840..86e6fbf711 100644 --- a/test/build_stream/nft/unit/orchestrator/logs/test_stage_log_metadata.py +++ b/test/build_stream/nft/unit/orchestrator/logs/test_stage_log_metadata.py @@ -56,7 +56,7 @@ class TestStageLogMetadata: def test_stages_with_and_without_logs(self, service): """GIVEN 3 stages, 2 with logs on disk THEN has_log/log_lines match the files.""" stages = [ - _stage("parse-catalog", StageState.COMPLETED, "parse.log"), + _stage("create-local-repository", StageState.COMPLETED, "parse.log"), _stage("build-image", StageState.COMPLETED, "build.log"), _stage("deploy", StageState.COMPLETED, "deploy.log"), ] @@ -102,3 +102,10 @@ def test_path_violation_withheld_and_never_raises(self, service): log_file_path="/etc/passwd", ) assert service.describe(JOB, stage) == StageLogMetadata(False, 0) + + + @pytest.mark.parametrize("name", ["parse-catalog", "upload"]) + def test_non_playbook_stage_never_has_log(self, service, name): + """GIVEN a non-playbook stage with a recorded file THEN has_log=false.""" + stage = _stage(name, StageState.COMPLETED, "parse.log") + assert service.describe(JOB, stage) == StageLogMetadata(False, 0) diff --git a/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py b/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py index 8221c9300d..2b17c5c795 100644 --- a/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py +++ b/test/build_stream/nft/unit/playbook_watcher/test_write_time_redaction.py @@ -28,6 +28,7 @@ import importlib.util import subprocess import sys +import time import uuid from pathlib import Path @@ -201,3 +202,24 @@ def _fake_run(cmd, playbook_dir, env, timeout_seconds, log_file_path): # pylint }) assert result["status"] == "success" assert "ANSIBLE_LOG_PATH" not in captured["env"] + + +def test_write_time_redaction_throughput_budget(tmp_path): + """TC-NFT-003: redacting 10000 realistic lines stays far below the playbook runtime. + + The HLD budget (<10% added write latency) is measured against Ansible's own + output rate, which is unobservable on a file-only baseline. The enforceable + local bound is an absolute per-line cost. + """ + body = ( + "for i in range(10000):\n" + " print(f'TASK [role : step {i}] ok: [10.0.0.{i % 250}] => changed=false " + "msg=\"package kernel-5.14.{i} installed\"')\n" + ) + start = time.perf_counter() + rc, text = _run(tmp_path, _python_cmd(body), timeout_seconds=60) + elapsed = time.perf_counter() - start + assert rc == 0 + assert len(text.splitlines()) == 10000 + assert "10.0.0.5" in text + assert elapsed < 2.0 From b6042e9e9654c290ff1d2fdae5d2df4dc4e921d1 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 09:09:15 +0530 Subject: [PATCH 12/15] fix(build_stream): refresh expired token in CI log preview hook (ER-BSM-003-ci-console-and-parity) Access tokens expire after 60 minutes, so after long playbook stages the after_script preview got 401. Mint a fresh token from CLIENT_ID/CLIENT_SECRET when the saved token is missing or rejected, in the build, cadence and deploy-child templates. Credentials are never printed and the hook never changes the job status. Signed-off-by: SOWJANYAJAGADISH123 --- .../hosted_gitlab/files/.gitlab-ci-build.yml | 43 ++++++++++++++++--- .../files/.gitlab-ci-cadence.yml | 43 ++++++++++++++++--- .../.gitlab-ci-deploy-child-template.yml | 43 ++++++++++++++++--- 3 files changed, 114 insertions(+), 15 deletions(-) diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml index fcb337254f..ce44c196bf 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml @@ -387,13 +387,46 @@ default: if [ -z "${_LOG_JOB}" ]; then echo " [LOG PREVIEW] No BSM job associated with this stage; skipping execution-log preview." else - if [ -n "${_LOG_STAGE}" ] && [ -n "${_LOG_TOKEN}" ] && command -v curl >/dev/null 2>&1; then + if [ -n "${_LOG_STAGE}" ] && command -v curl >/dev/null 2>&1; then _LOG_CA_ARG="" [ -f "${_LOG_CACERT}" ] && _LOG_CA_ARG="--cacert ${_LOG_CACERT}" - _LOG_CODE=$(curl -s -o /tmp/_bsm_logtail.json -w "%{http_code}" \ - --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ - -H "Authorization: Bearer ${_LOG_TOKEN}" ${_LOG_CA_ARG} \ - "${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}&tail_lines=${_LOG_TAIL}" 2>/dev/null || echo "000") + # Access tokens expire (default 60 min). Mint a fresh one when the saved + # token is missing or rejected; CLIENT_ID/CLIENT_SECRET are never printed. + _bsm_new_token() { + if [ -z "${CLIENT_ID:-}" ] || [ -z "${CLIENT_SECRET:-}" ] || ! command -v jq >/dev/null 2>&1; then + return 1 + fi + curl -s -o /tmp/_bsm_tok.json \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ + ${_LOG_CA_ARG} -X POST "${BSM_API_URL}/api/v1/auth/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}" \ + >/dev/null 2>&1 || { rm -f /tmp/_bsm_tok.json; return 1; } + _NEW_TOKEN=$(jq -r '.access_token // empty' /tmp/_bsm_tok.json 2>/dev/null) + rm -f /tmp/_bsm_tok.json + if [ -z "${_NEW_TOKEN}" ]; then + return 1 + fi + _LOG_TOKEN="${_NEW_TOKEN}" + } + _bsm_fetch_log() { + curl -s -o /tmp/_bsm_logtail.json -w "%{http_code}" \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ + -H "Authorization: Bearer ${_LOG_TOKEN}" ${_LOG_CA_ARG} \ + "${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}&tail_lines=${_LOG_TAIL}" 2>/dev/null || echo "000" + } + if [ -z "${_LOG_TOKEN}" ]; then + _bsm_new_token || true + fi + _LOG_CODE="000" + if [ -n "${_LOG_TOKEN}" ]; then + _LOG_CODE=$(_bsm_fetch_log) + fi + if [ "${_LOG_CODE}" = "401" ]; then + if _bsm_new_token; then + _LOG_CODE=$(_bsm_fetch_log) + fi + fi if [ "${_LOG_CODE}" = "200" ] && command -v jq >/dev/null 2>&1; then echo " ============================================================" echo " Execution Log (last ${_LOG_TAIL} lines) - stage ${_LOG_STAGE}" diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml index 8193ef9fa4..a2651052e0 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml @@ -254,13 +254,46 @@ default: if [ -z "${_LOG_JOB}" ]; then echo " [LOG PREVIEW] No BSM job associated with this stage; skipping execution-log preview." else - if [ -n "${_LOG_STAGE}" ] && [ -n "${_LOG_TOKEN}" ] && command -v curl >/dev/null 2>&1; then + if [ -n "${_LOG_STAGE}" ] && command -v curl >/dev/null 2>&1; then _LOG_CA_ARG="" [ -f "${_LOG_CACERT}" ] && _LOG_CA_ARG="--cacert ${_LOG_CACERT}" - _LOG_CODE=$(curl -s -o /tmp/_bsm_logtail.json -w "%{http_code}" \ - --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ - -H "Authorization: Bearer ${_LOG_TOKEN}" ${_LOG_CA_ARG} \ - "${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}&tail_lines=${_LOG_TAIL}" 2>/dev/null || echo "000") + # Access tokens expire (default 60 min). Mint a fresh one when the saved + # token is missing or rejected; CLIENT_ID/CLIENT_SECRET are never printed. + _bsm_new_token() { + if [ -z "${CLIENT_ID:-}" ] || [ -z "${CLIENT_SECRET:-}" ] || ! command -v jq >/dev/null 2>&1; then + return 1 + fi + curl -s -o /tmp/_bsm_tok.json \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ + ${_LOG_CA_ARG} -X POST "${BSM_API_URL}/api/v1/auth/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}" \ + >/dev/null 2>&1 || { rm -f /tmp/_bsm_tok.json; return 1; } + _NEW_TOKEN=$(jq -r '.access_token // empty' /tmp/_bsm_tok.json 2>/dev/null) + rm -f /tmp/_bsm_tok.json + if [ -z "${_NEW_TOKEN}" ]; then + return 1 + fi + _LOG_TOKEN="${_NEW_TOKEN}" + } + _bsm_fetch_log() { + curl -s -o /tmp/_bsm_logtail.json -w "%{http_code}" \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ + -H "Authorization: Bearer ${_LOG_TOKEN}" ${_LOG_CA_ARG} \ + "${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}&tail_lines=${_LOG_TAIL}" 2>/dev/null || echo "000" + } + if [ -z "${_LOG_TOKEN}" ]; then + _bsm_new_token || true + fi + _LOG_CODE="000" + if [ -n "${_LOG_TOKEN}" ]; then + _LOG_CODE=$(_bsm_fetch_log) + fi + if [ "${_LOG_CODE}" = "401" ]; then + if _bsm_new_token; then + _LOG_CODE=$(_bsm_fetch_log) + fi + fi if [ "${_LOG_CODE}" = "200" ] && command -v jq >/dev/null 2>&1; then echo " ============================================================" echo " Execution Log (last ${_LOG_TAIL} lines) - stage ${_LOG_STAGE}" diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml index 944ecdf9ea..a0817a6362 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml @@ -347,13 +347,46 @@ default: if [ -z "${_LOG_JOB}" ]; then echo " [LOG PREVIEW] No BSM job associated with this stage; skipping execution-log preview." else - if [ -n "${_LOG_STAGE}" ] && [ -n "${_LOG_TOKEN}" ] && command -v curl >/dev/null 2>&1; then + if [ -n "${_LOG_STAGE}" ] && command -v curl >/dev/null 2>&1; then _LOG_CA_ARG="" [ -f "${_LOG_CACERT}" ] && _LOG_CA_ARG="--cacert ${_LOG_CACERT}" - _LOG_CODE=$(curl -s -o /tmp/_bsm_logtail.json -w "%{http_code}" \ - --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ - -H "Authorization: Bearer ${_LOG_TOKEN}" ${_LOG_CA_ARG} \ - "${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}&tail_lines=${_LOG_TAIL}" 2>/dev/null || echo "000") + # Access tokens expire (default 60 min). Mint a fresh one when the saved + # token is missing or rejected; CLIENT_ID/CLIENT_SECRET are never printed. + _bsm_new_token() { + if [ -z "${CLIENT_ID:-}" ] || [ -z "${CLIENT_SECRET:-}" ] || ! command -v jq >/dev/null 2>&1; then + return 1 + fi + curl -s -o /tmp/_bsm_tok.json \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ + ${_LOG_CA_ARG} -X POST "${BSM_API_URL}/api/v1/auth/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}" \ + >/dev/null 2>&1 || { rm -f /tmp/_bsm_tok.json; return 1; } + _NEW_TOKEN=$(jq -r '.access_token // empty' /tmp/_bsm_tok.json 2>/dev/null) + rm -f /tmp/_bsm_tok.json + if [ -z "${_NEW_TOKEN}" ]; then + return 1 + fi + _LOG_TOKEN="${_NEW_TOKEN}" + } + _bsm_fetch_log() { + curl -s -o /tmp/_bsm_logtail.json -w "%{http_code}" \ + --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ + -H "Authorization: Bearer ${_LOG_TOKEN}" ${_LOG_CA_ARG} \ + "${BSM_API_URL}/api/v1/jobs/${_LOG_JOB}/logs?stage=${_LOG_STAGE}&tail_lines=${_LOG_TAIL}" 2>/dev/null || echo "000" + } + if [ -z "${_LOG_TOKEN}" ]; then + _bsm_new_token || true + fi + _LOG_CODE="000" + if [ -n "${_LOG_TOKEN}" ]; then + _LOG_CODE=$(_bsm_fetch_log) + fi + if [ "${_LOG_CODE}" = "401" ]; then + if _bsm_new_token; then + _LOG_CODE=$(_bsm_fetch_log) + fi + fi if [ "${_LOG_CODE}" = "200" ] && command -v jq >/dev/null 2>&1; then echo " ============================================================" echo " Execution Log (last ${_LOG_TAIL} lines) - stage ${_LOG_STAGE}" From 3e8d136b7c1a0b59db24496a50722de772724749 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 09:09:15 +0530 Subject: [PATCH 13/15] test(build_stream): cover CI hook token refresh and errexit safety (ER-BSM-003-ci-console-and-parity) Signed-off-by: SOWJANYAJAGADISH123 --- .../test_after_script_log_preview.py | 90 ++++++++++++++++++- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py b/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py index ce3a545002..efab3a40ea 100644 --- a/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py +++ b/test/build_stream/nft/unit/ci_console/test_after_script_log_preview.py @@ -25,6 +25,7 @@ import http.server import json +import os import subprocess import threading import time @@ -76,8 +77,27 @@ class _LogHandler(http.server.BaseHTTPRequestHandler): ], } + valid_token = None + token_requests = 0 + issued_token = "fresh-token" + + def do_POST(self): # noqa: N802 + """Mock /api/v1/auth/token issuing ``issued_token``.""" + type(self).token_requests += 1 + self.rfile.read(int(self.headers.get("Content-Length", 0))) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"access_token": self.issued_token}).encode("utf-8")) + def do_GET(self): # noqa: N802 - """Serve the configured JSON payload and status.""" + """Serve the configured JSON payload and status (401 for a stale token).""" + if self.valid_token and self.headers.get("Authorization") != f"Bearer {self.valid_token}": + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "token_expired"}') + return self.send_response(self.status) self.send_header("Content-Type", "application/json") self.end_headers() @@ -87,6 +107,9 @@ def log_message(self, *_args): """Silence the default stderr access log.""" +DEFAULT_PAYLOAD = dict(_LogHandler.payload) + + @pytest.fixture def bsm_server(): """Start a mock BSM API; return a controller to set status/payload and URL.""" @@ -99,12 +122,14 @@ class _Ctl: url = f"http://{host}:{port}" @staticmethod - def set(status=200, payload=None): + def set(status=200, payload=None, valid_token=None): _LogHandler.status = status + _LogHandler.valid_token = valid_token + _LogHandler.token_requests = 0 if payload is not None: _LogHandler.payload = payload - _Ctl.set(200, _LogHandler.payload) + _Ctl.set(200, DEFAULT_PAYLOAD) yield _Ctl server.shutdown() @@ -229,3 +254,62 @@ def test_after_script_overhead_under_two_seconds(tmp_path, bsm_server): }) assert proc.returncode == 0 assert time.perf_counter() - start < 2.0 + + +_REFRESH_ENV = { + "CI_JOB_STAGE": "build-images", + "JOB_ID": "job-1", + "CLIENT_ID": "bsm-client", + "CLIENT_SECRET": "super-secret-value", +} + + +def test_expired_token_is_refreshed_and_preview_shown(tmp_path, bsm_server): + """GIVEN a stale ACCESS_TOKEN WHEN the API answers 401 THEN a fresh token is used.""" + bsm_server.set(valid_token="fresh-token") + proc = _run(tmp_path, "build", { + **_REFRESH_ENV, "BSM_API_URL": bsm_server.url, "ACCESS_TOKEN": "stale-token", + }) + assert proc.returncode == 0 + assert "PLAY RECAP ok=3" in proc.stdout + assert _LogHandler.token_requests == 1 + assert "super-secret-value" not in proc.stdout + proc.stderr + assert "fresh-token" not in proc.stdout + proc.stderr + + +def test_missing_token_is_minted_from_client_credentials(tmp_path, bsm_server): + """GIVEN no saved token and client credentials THEN the hook obtains one itself.""" + bsm_server.set(valid_token="fresh-token") + proc = _run(tmp_path, "build", { + **_REFRESH_ENV, "BSM_API_URL": bsm_server.url, "ACCESS_TOKEN": "", + }) + assert proc.returncode == 0 + assert "PLAY RECAP ok=3" in proc.stdout + + +def test_expired_token_without_credentials_is_non_fatal(tmp_path, bsm_server): + """GIVEN a stale token and no client credentials THEN a warning is printed, job unaffected.""" + bsm_server.set(valid_token="fresh-token") + proc = _run(tmp_path, "build", { + "CI_JOB_STAGE": "build-images", "JOB_ID": "job-1", + "BSM_API_URL": bsm_server.url, "ACCESS_TOKEN": "stale-token", + }) + assert proc.returncode == 0 + assert "Log preview unavailable (HTTP 401)" in proc.stdout + assert "curl -H 'Authorization: Bearer '" in proc.stdout + + +@pytest.mark.parametrize("template_key", sorted(TEMPLATES)) +def test_hook_never_fails_under_errexit(tmp_path, bsm_server, template_key): + """GIVEN a runner that uses 'sh -e' WHEN the API is unreachable THEN the exit code is 0.""" + script = tmp_path / "errexit.sh" + script.write_text(_after_script(TEMPLATES[template_key]), encoding="utf-8") + env = { + "CI_JOB_STAGE": PLAYBOOK_STAGE[template_key], "JOB_ID": "job-1", + "BSM_API_URL": "http://127.0.0.1:9", "ACCESS_TOKEN": "", "CURL_MAX_TIME": "2", + "CURL_CONNECT_TIMEOUT": "1", "PATH": os.environ["PATH"], + } + proc = subprocess.run(["sh", "-e", str(script)], capture_output=True, text=True, + env=env, check=False) + assert proc.returncode == 0 + assert "Retrieve complete execution logs" in proc.stdout From d48cf98c7ffd03b234a524ad7ce633d7aa961c45 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 20:59:34 +0530 Subject: [PATCH 14/15] fix(build_stream): make stage logs readable by BSM and print in CI after_script (ER-BSM-003) - publish_stage_log: use copyfile + chmod 0644 + chcon instead of copy2 so the job log copy is not left as var_log_t/0600 (unreadable by the BSM container, which made the logs API return 404 / has_log=false). - CI after_script: fall back to BSM_CLIENT_ID/BSM_CLIENT_SECRET when CLIENT_ID/CLIENT_SECRET are unset so a token can be minted. - deploy child pipeline: export CLIENT_ID/CLIENT_SECRET from BSM_CLIENT_* so deploy/restart/validate jobs can fetch the execution log preview. Signed-off-by: SOWJANYAJAGADISH123 --- .../playbook_watcher_service.py | 17 ++++++++++++++++- .../hosted_gitlab/files/.gitlab-ci-build.yml | 6 ++++-- .../hosted_gitlab/files/.gitlab-ci-cadence.yml | 6 ++++-- .../files/.gitlab-ci-deploy-child-template.yml | 6 ++++-- .../hosted_gitlab/files/.gitlab-ci-deploy.yml | 2 ++ 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/build_stream/app/playbook-watcher/playbook_watcher_service.py b/src/build_stream/app/playbook-watcher/playbook_watcher_service.py index 72be72cd9b..78785d3a59 100644 --- a/src/build_stream/app/playbook-watcher/playbook_watcher_service.py +++ b/src/build_stream/app/playbook-watcher/playbook_watcher_service.py @@ -1025,6 +1025,17 @@ def build_stage_log_path( # pylint: disable=too-many-arguments,too-many-positio ) +def _relabel_like_parent(path: Path) -> None: + """Give ``path`` the SELinux label of its directory (no-op without SELinux).""" + try: + subprocess.run( + ["chcon", "--reference", str(path.parent), str(path)], + check=False, capture_output=True, timeout=10, + ) + except (OSError, subprocess.SubprocessError): + pass + + def publish_stage_log( domain_log_path: Path, job_id: str, summary_lines: List[str] ) -> str: @@ -1047,7 +1058,11 @@ def publish_stage_log( job_log_path = HOST_LOG_BASE_DIR / job_id / domain_log_path.name try: job_log_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(str(domain_log_path), str(job_log_path)) + # copyfile (not copy2): copy2 carries over the source SELinux label + # (var_log_t) and 0600 mode, which the BSM container cannot read. + shutil.copyfile(str(domain_log_path), str(job_log_path)) + os.chmod(job_log_path, 0o644) + _relabel_like_parent(job_log_path) except (OSError, shutil.Error): log_secure_info( "error", diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml index ce44c196bf..31d873b428 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-build.yml @@ -393,14 +393,16 @@ default: # Access tokens expire (default 60 min). Mint a fresh one when the saved # token is missing or rejected; CLIENT_ID/CLIENT_SECRET are never printed. _bsm_new_token() { - if [ -z "${CLIENT_ID:-}" ] || [ -z "${CLIENT_SECRET:-}" ] || ! command -v jq >/dev/null 2>&1; then + _TOK_ID="${CLIENT_ID:-${BSM_CLIENT_ID:-}}" + _TOK_SECRET="${CLIENT_SECRET:-${BSM_CLIENT_SECRET:-}}" + if [ -z "${_TOK_ID}" ] || [ -z "${_TOK_SECRET}" ] || ! command -v jq >/dev/null 2>&1; then return 1 fi curl -s -o /tmp/_bsm_tok.json \ --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ ${_LOG_CA_ARG} -X POST "${BSM_API_URL}/api/v1/auth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}" \ + -d "grant_type=client_credentials&client_id=${_TOK_ID}&client_secret=${_TOK_SECRET}" \ >/dev/null 2>&1 || { rm -f /tmp/_bsm_tok.json; return 1; } _NEW_TOKEN=$(jq -r '.access_token // empty' /tmp/_bsm_tok.json 2>/dev/null) rm -f /tmp/_bsm_tok.json diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml index a2651052e0..16d6416a18 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-cadence.yml @@ -260,14 +260,16 @@ default: # Access tokens expire (default 60 min). Mint a fresh one when the saved # token is missing or rejected; CLIENT_ID/CLIENT_SECRET are never printed. _bsm_new_token() { - if [ -z "${CLIENT_ID:-}" ] || [ -z "${CLIENT_SECRET:-}" ] || ! command -v jq >/dev/null 2>&1; then + _TOK_ID="${CLIENT_ID:-${BSM_CLIENT_ID:-}}" + _TOK_SECRET="${CLIENT_SECRET:-${BSM_CLIENT_SECRET:-}}" + if [ -z "${_TOK_ID}" ] || [ -z "${_TOK_SECRET}" ] || ! command -v jq >/dev/null 2>&1; then return 1 fi curl -s -o /tmp/_bsm_tok.json \ --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ ${_LOG_CA_ARG} -X POST "${BSM_API_URL}/api/v1/auth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}" \ + -d "grant_type=client_credentials&client_id=${_TOK_ID}&client_secret=${_TOK_SECRET}" \ >/dev/null 2>&1 || { rm -f /tmp/_bsm_tok.json; return 1; } _NEW_TOKEN=$(jq -r '.access_token // empty' /tmp/_bsm_tok.json 2>/dev/null) rm -f /tmp/_bsm_tok.json diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml index a0817a6362..a42f2ab774 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy-child-template.yml @@ -353,14 +353,16 @@ default: # Access tokens expire (default 60 min). Mint a fresh one when the saved # token is missing or rejected; CLIENT_ID/CLIENT_SECRET are never printed. _bsm_new_token() { - if [ -z "${CLIENT_ID:-}" ] || [ -z "${CLIENT_SECRET:-}" ] || ! command -v jq >/dev/null 2>&1; then + _TOK_ID="${CLIENT_ID:-${BSM_CLIENT_ID:-}}" + _TOK_SECRET="${CLIENT_SECRET:-${BSM_CLIENT_SECRET:-}}" + if [ -z "${_TOK_ID}" ] || [ -z "${_TOK_SECRET}" ] || ! command -v jq >/dev/null 2>&1; then return 1 fi curl -s -o /tmp/_bsm_tok.json \ --connect-timeout "${CURL_CONNECT_TIMEOUT:-15}" --max-time "${CURL_MAX_TIME:-45}" \ ${_LOG_CA_ARG} -X POST "${BSM_API_URL}/api/v1/auth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}" \ + -d "grant_type=client_credentials&client_id=${_TOK_ID}&client_secret=${_TOK_SECRET}" \ >/dev/null 2>&1 || { rm -f /tmp/_bsm_tok.json; return 1; } _NEW_TOKEN=$(jq -r '.access_token // empty' /tmp/_bsm_tok.json 2>/dev/null) rm -f /tmp/_bsm_tok.json diff --git a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy.yml b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy.yml index ae6f37d9e8..55e2f70a3a 100644 --- a/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy.yml +++ b/src/build_stream/roles/hosted_gitlab/files/.gitlab-ci-deploy.yml @@ -230,6 +230,8 @@ list_images: lines.append(' BSM_CERT_FILE: "/tmp/bsm_api_ca.crt"') lines.append(f' CURL_CONNECT_TIMEOUT: "{curl_connect_timeout}"') lines.append(f' CURL_MAX_TIME: "{curl_max_time}"') + lines.append(' CLIENT_ID: "$BSM_CLIENT_ID"') + lines.append(' CLIENT_SECRET: "$BSM_CLIENT_SECRET"') lines.append("") # --- Stages --- From 86a925f7d1414cf5255a0c442fc9f5ff3c472c69 Mon Sep 17 00:00:00 2001 From: SOWJANYAJAGADISH123 Date: Sun, 15 Nov 2026 21:01:24 +0530 Subject: [PATCH 15/15] fix(orchestrator): pin OpenCHAMI RPM download URL to v0.2.0 release The deploy_openchami role resolved the latest release tag via the GitHub API and built /openchami-0.2.0.rpm. When the latest tag moved to v0.2.1 the asset name stayed openchami-0.2.0.rpm, so the URL returned HTTP 404 and OpenCHAMI installation failed after all retries. Use a fixed openchami_rpm_url (v0.2.0) and drop the release-metadata lookup and the URL-derivation task. Signed-off-by: SOWJANYAJAGADISH123 --- .../deploy_openchami/tasks/configs/ochami.yml | 16 ---------------- .../roles/deploy_openchami/vars/main.yml | 3 +-- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/src/orchestrator/roles/deploy_openchami/tasks/configs/ochami.yml b/src/orchestrator/roles/deploy_openchami/tasks/configs/ochami.yml index db19cb7620..1e50cdd8d5 100644 --- a/src/orchestrator/roles/deploy_openchami/tasks/configs/ochami.yml +++ b/src/orchestrator/roles/deploy_openchami/tasks/configs/ochami.yml @@ -18,22 +18,6 @@ path: "{{ openchami_work_dir }}/{{ openchami_rpm_name }}" register: _openchami_rpm_stat -- name: Fetch latest OpenCHAMI release metadata - ansible.builtin.uri: - url: "{{ openchami_release_api_url }}" - return_content: true - register: openchami_release_info - retries: "{{ max_retries }}" - delay: "{{ max_delay }}" - until: openchami_release_info.status == 200 - when: not _openchami_rpm_stat.stat.exists - -- name: Determine RPM download URL from release assets - ansible.builtin.set_fact: - openchami_rpm_url: >- - https://github.com/{{ openchami_release_repo }}/releases/download/{{ openchami_release_info.json.tag_name }}/{{ openchami_rpm_name }} - when: not _openchami_rpm_stat.stat.exists - - name: Download the OpenCHAMI RPM from GitHub releases ansible.builtin.get_url: url: "{{ openchami_rpm_url }}" diff --git a/src/orchestrator/roles/deploy_openchami/vars/main.yml b/src/orchestrator/roles/deploy_openchami/vars/main.yml index 9886ee0087..d1a7d7e27e 100644 --- a/src/orchestrator/roles/deploy_openchami/vars/main.yml +++ b/src/orchestrator/roles/deploy_openchami/vars/main.yml @@ -93,9 +93,8 @@ ochami_cli_log_format: rfc3339 ochami_base_url: "https://{{ cluster_name }}.{{ cluster_domain }}:8443" # OpenCHAMI RPM — downloaded from GitHub releases at install time -openchami_release_repo: OpenCHAMI/release -openchami_release_api_url: "https://api.github.com/repos/{{ openchami_release_repo }}/releases/latest" openchami_rpm_name: openchami-0.2.0.rpm +openchami_rpm_url: "https://github.com/OpenCHAMI/release/releases/download/v0.2.0/{{ openchami_rpm_name }}" ochami_client_rpm_url: https://github.com/OpenCHAMI/ochami/releases/download/v0.10.2/ochami_0.10.2_linux_amd64.rpm ochami_client_rpm_name: ochami_0.10.2_linux_amd64.rpm