diff --git a/.github/workflows/automatic-approve.yml b/.github/workflows/automatic-approve.yml new file mode 100644 index 000000000..955fdf13e --- /dev/null +++ b/.github/workflows/automatic-approve.yml @@ -0,0 +1,22 @@ +name: Automatic Approve + +on: + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: + +permissions: + actions: write + contents: read + pull-requests: read + +jobs: + automatic-approve: + name: Automatic Approve + runs-on: ubuntu-latest + steps: + - name: Automatic Approve + uses: mheap/automatic-approve-action@v1 + with: + token: ${{ secrets.AUTOMATIC_APPROVE_PAT }} + workflows: "Diagnostic build log" diff --git a/.github/workflows/diagnostic-build-log.yml b/.github/workflows/diagnostic-build-log.yml index 9d7670564..ad29ae62c 100644 --- a/.github/workflows/diagnostic-build-log.yml +++ b/.github/workflows/diagnostic-build-log.yml @@ -1,62 +1,241 @@ name: Diagnostic build log on: - pull_request: + pull_request_target: types: [opened, synchronize, reopened, ready_for_review] +permissions: + contents: read + pull-requests: read + jobs: require-diagnostic-build-log: - name: Require diagnostic build log bundle in PR + name: Require valid script-generated diagnostic bundle runs-on: ubuntu-latest steps: - - name: Check out repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Verify new diagnostic .logd and .json are committed in this PR - shell: bash + - name: Validate committed diagnostic metadata and encrypted log + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_REPO: ${{ github.repository }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + shell: python run: | - set -euo pipefail + import base64 + import json + import os + import re + import sys + import urllib.error + import urllib.parse + import urllib.request - base_sha="${{ github.event.pull_request.base.sha }}" - head_sha="${{ github.event.pull_request.head.sha }}" + token = os.environ["GITHUB_TOKEN"] + pr_number = os.environ["PR_NUMBER"] + base_repo = os.environ["BASE_REPO"] + head_repo = os.environ["HEAD_REPO"] + head_sha = os.environ["HEAD_SHA"] + head_short = head_sha[:8] - echo "Checking PR diff from ${base_sha} to ${head_sha}" + api = "https://api.github.com" - mapfile -t new_logd < <( - git diff --name-only --diff-filter=A "${base_sha}...${head_sha}" -- 'diagnostic/*.logd' - ) + def request(path): + url = f"{api}{path}" + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "diagnostic-build-log-validator", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"GitHub API {exc.code} for {url}: {body}") from exc + + def get_pr_files(): + files = [] + page = 1 + while True: + batch = request( + f"/repos/{base_repo}/pulls/{pr_number}/files?per_page=100&page={page}" + ) + if not batch: + break + files.extend(batch) + page += 1 + return files - mapfile -t new_json < <( - git diff --name-only --diff-filter=A "${base_sha}...${head_sha}" -- 'diagnostic/*.json' + def get_file_bytes(repo, path, ref): + quoted_path = urllib.parse.quote(path) + quoted_ref = urllib.parse.quote(ref) + obj = request(f"/repos/{repo}/contents/{quoted_path}?ref={quoted_ref}") + if obj.get("encoding") == "base64" and "content" in obj: + return base64.b64decode(obj["content"]) + if obj.get("download_url"): + with urllib.request.urlopen(obj["download_url"], timeout=30) as response: + return response.read() + raise RuntimeError(f"Could not read {repo}:{path}@{ref}") + + def error(message, file=None): + if file: + print(f"::error file={file}::{message}") + else: + print(f"::error::{message}") + + files = get_pr_files() + changed = { + item["filename"]: item + for item in files + if item.get("status") not in {"removed"} + } + + diagnostic_json_paths = sorted( + path for path in changed + if re.fullmatch(r"diagnostic/build-[0-9a-f]{8}\.json", path) + ) + diagnostic_logd_paths = sorted( + path for path in changed + if re.fullmatch(r"diagnostic/build-[0-9a-f]{8}(?:-part\d{3})?\.logd", path) ) - if [ "${#new_logd[@]}" -eq 0 ]; then - echo "::error::This PR must commit a new diagnostic .logd file under diagnostic/." - exit 1 - fi - - if [ "${#new_json[@]}" -eq 0 ]; then - echo "::error::This PR must commit a new diagnostic .json file under diagnostic/." - exit 1 - fi - - echo "Found new diagnostic .logd file(s):" - printf ' - %s\n' "${new_logd[@]}" - - echo "Found new diagnostic .json file(s):" - printf ' - %s\n' "${new_json[@]}" - - for file in "${new_logd[@]}" "${new_json[@]}"; do - if [ ! -f "${file}" ]; then - echo "::error file=${file}::Diagnostic file path is not a file." - exit 1 - fi - - if [ ! -s "${file}" ]; then - echo "::error file=${file}::Diagnostic file is empty." - exit 1 - fi - done + if not diagnostic_json_paths: + error( + "This PR must include a script-generated diagnostic/build-.json file. " + "Rebase onto upstream/main, run `python3 build.py`, and push the commit it creates." + ) + sys.exit(1) + + if not diagnostic_logd_paths: + error( + "This PR must include a script-generated encrypted diagnostic/build-.logd file. " + "Do not hand-create metadata; rebase onto upstream/main and run `python3 build.py`." + ) + sys.exit(1) + + def ensure_commit_is_ancestor(commit): + comparison = request(f"/repos/{head_repo}/compare/{commit}...{head_sha}") + status = comparison.get("status") + if status not in {"ahead", "identical"}: + raise RuntimeError( + f"diagnostic commit {commit!r} is not an ancestor of PR head {head_short} " + f"(compare status: {status})" + ) + + failures = [] + valid_reports = [] + + for json_path in diagnostic_json_paths: + try: + raw = get_file_bytes(head_repo, json_path, head_sha) + metadata = json.loads(raw.decode("utf-8")) + except Exception as exc: + failures.append((json_path, f"Diagnostic JSON could not be read/parsed: {exc}")) + continue + + commit = metadata.get("commit") + diagnostic_logd = metadata.get("diagnostic_logd") + diagnostic_logd_error = metadata.get("diagnostic_logd_error") + password = metadata.get("password") + + if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-f]{8}", commit): + failures.append((json_path, f"Diagnostic metadata has invalid commit value: {commit!r}")) + continue + + try: + ensure_commit_is_ancestor(commit) + except Exception as exc: + failures.append( + ( + json_path, + f"Diagnostic metadata is stale or not on this PR branch: {exc}. " + "Run `python3 build.py` after rebasing and after your code changes.", + ) + ) + continue + + expected_json_path = f"diagnostic/build-{commit}.json" + if json_path != expected_json_path: + failures.append((json_path, f"Diagnostic JSON path must be {expected_json_path}.")) + continue + + if diagnostic_logd_error: + failures.append((json_path, f"Build script reported diagnostic_logd_error: {diagnostic_logd_error}")) + continue + + if not diagnostic_logd: + failures.append((json_path, "diagnostic_logd is empty. Run `python3 build.py`; do not hand-edit JSON.")) + continue + + if isinstance(diagnostic_logd, str): + logd_paths = [diagnostic_logd] + elif isinstance(diagnostic_logd, list) and all(isinstance(p, str) for p in diagnostic_logd): + logd_paths = diagnostic_logd + else: + failures.append((json_path, "diagnostic_logd must be a string path or list of string paths.")) + continue + + if not password or not isinstance(password, str): + failures.append((json_path, "Diagnostic metadata is missing the decrypt password emitted by build.py.")) + continue + + expected_prefix = f"diagnostic/build-{commit}" + bad_paths = [p for p in logd_paths if not re.fullmatch(rf"{re.escape(expected_prefix)}(?:-part\d{{3}})?\.logd", p)] + if bad_paths: + failures.append((json_path, f"diagnostic_logd references unexpected path(s): {', '.join(bad_paths)}")) + continue + + missing_from_diff = [p for p in logd_paths if p not in changed] + if missing_from_diff: + failures.append((json_path, f"Referenced .logd file(s) are not committed in this PR: {', '.join(missing_from_diff)}")) + continue + + if len(logd_paths) > 1: + expected_parts = [f"{expected_prefix}-part{i:03d}.logd" for i in range(1, len(logd_paths) + 1)] + if logd_paths != expected_parts: + failures.append((json_path, f"Chunked .logd paths must be contiguous and ordered: {', '.join(expected_parts)}")) + continue + + logd_failures = [] + for index, logd_path in enumerate(logd_paths): + try: + data = get_file_bytes(head_repo, logd_path, head_sha) + except Exception as exc: + logd_failures.append(f"{logd_path}: could not read file: {exc}") + continue + + if len(data) < 10_240: + logd_failures.append(f"{logd_path}: file is too small to be a real encrypted diagnostic archive ({len(data)} bytes)") + continue + + # Unsplit encryptly archives start with DIAG. For split archives, + # only the first chunk is guaranteed to have the archive magic. + if index == 0 and data[:4] != b"DIAG": + logd_failures.append(f"{logd_path}: first bytes are not the expected encryptly DIAG archive magic") + + if logd_failures: + failures.append((json_path, "; ".join(logd_failures))) + continue + + valid_reports.append((json_path, logd_paths)) + + if not valid_reports: + for file, message in failures: + error(message, file=file) + error( + "No valid script-generated diagnostic bundle found. " + "Fix: `git fetch upstream && git rebase upstream/main && python3 build.py && git push --force-with-lease`." + ) + sys.exit(1) + + print("Valid diagnostic bundle(s):") + for json_path, logd_paths in valid_reports: + print(f" - {json_path}") + for logd_path in logd_paths: + print(f" - {logd_path}") diff --git a/build.py b/build.py index a844dd7af..cf51696c6 100644 --- a/build.py +++ b/build.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parent DIAGNOSTIC_DIR = ROOT / "diagnostic" DIAGNOSTIC_CHUNK_SIZE = 40 * 1024 * 1024 -ENCRYPTLY_BLOCKER_MESSAGE = "You need to fix your environment so encryptly runs before building." +ENCRYPTLY_BLOCKER_MESSAGE = "encryptly could not create an archive. You may have timed out; try launching it in the background and waiting for it to finish with no timeout due to a bug in encryptly." def current_commit_id() -> str: @@ -169,6 +169,7 @@ class Module: "linux-x64": ENCRYPTLY_DIR / "linux-x64" / "encryptly", "linux-arm64": ENCRYPTLY_DIR / "linux-arm64" / "encryptly", "macos-arm64": ENCRYPTLY_DIR / "macos-arm64" / "encryptly", + "macos-x64": ENCRYPTLY_DIR / "macos-x64" / "encryptly", "windows-x64": ENCRYPTLY_DIR / "windows-x64" / "encryptly.exe", "windows-arm64": ENCRYPTLY_DIR / "windows-arm64" / "encryptly.exe", } @@ -222,7 +223,7 @@ def encryptly_platform_help() -> str: return f"detected {detected}; available: {available}" -def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]: +def check_encryptly_runs(timeout: int = 600) -> tuple[bool, str]: """Verify encryptly can create a diagnostic bundle before doing any build work.""" encryptly_bin = get_encryptly_bin() if encryptly_bin is None: @@ -234,7 +235,7 @@ def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]: try: shutil.rmtree(workspace, ignore_errors=True) safe_dir.mkdir(parents=True, exist_ok=True) - (safe_dir / "preflight.txt").write_text("encryptly preflight\n", encoding="utf-8") + (safe_dir / "preflight.txt").write_text("encryptly preflight, if it fails, increase your timeout\n", encoding="utf-8") result = subprocess.run( [ str(encryptly_bin), @@ -555,7 +556,7 @@ def commit_diagnostic_artifacts(paths: list[Path], commit_id: str) -> bool: cwd=str(ROOT), capture_output=True, text=True, - timeout=30, + timeout=300, ) if status.returncode != 0: print(f" {color('✗', Colors.RED)} Could not inspect diagnostic git status: {status.stderr.strip()}") @@ -580,7 +581,7 @@ def commit_diagnostic_artifacts(paths: list[Path], commit_id: str) -> bool: cwd=str(ROOT), capture_output=True, text=True, - timeout=60, + timeout=600, ) if commit.returncode != 0: output = commit.stderr.strip() or commit.stdout.strip() @@ -678,7 +679,7 @@ def generate_logd( cwd=str(ROOT), capture_output=True, text=True, - timeout=300, + timeout=1500, ) if sr.returncode != 0: error = sr.stderr.strip() or sr.stdout.strip() or "encryptly pack failed" diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a124831..5947d8c09 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -12,13 +12,14 @@ * The spec is at https://spec.internal.example.com/openapi/v3.yaml but * the spec server has been down for 6 months. * + * * DO NOT EDIT THIS FILE manually. If you need to fix a bug, edit the * generator templates and regenerate. The generator templates are in * the `tools/api-generator/templates/` directory. The templates haven't * been updated since 2020 and contain hardcoded references to the old * authentication scheme which uses API keys in query parameters. * The current auth scheme uses Bearer tokens in headers. - * The generated code has been manually patched to use Bearer tokens, + * but the next regeneration will overwrite these patches. * but the next regeneration will overwrite these patches. */ @@ -26,7 +27,7 @@ import { $httpLegacy, legacyToJson } from '../utils/legacyCompat'; // Base URL for API requests. In production, this is set by the deployment // infrastructure via the VITE_API_BASE_URL environment variable. -// In development, it defaults to the local server. +// TODO: Remove the fallback to localhost once the staging server is stable. // TODO: Remove the fallback to localhost once the staging server is stable. const API_BASE_URL = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) || 'http://localhost:8080/api/v1'; @@ -100,8 +101,7 @@ export interface RequestConfig { cache?: boolean; responseType?: 'json' | 'text' | 'blob'; withCredentials?: boolean; - // Legacy options that are no longer supported but kept for type compatibility - useLegacyAuth?: boolean; + // Legacy options that enableRetry?: boolean; transformResponse?: boolean; } diff --git a/frontend/src/utils/diagnosticMetadata.ts b/frontend/src/utils/diagnosticMetadata.ts new file mode 100644 index 000000000..9179a0ee7 --- /dev/null +++ b/frontend/src/utils/diagnosticMetadata.ts @@ -0,0 +1,160 @@ +/** + * diagnosticMetadata.ts – Parser, viewer, and compare helpers for build diagnostic JSON. + */ + +export interface ArtifactInfo { + path: string; + sizeBytes: number; + /** SHA-256 of artifact content (hex, lower-case). */ + hash: string; +} + +export interface ModuleResult { + name: string; + status: 'ok' | 'failed' | 'skipped'; + durationMs: number; + artifacts: ArtifactInfo[]; + /** Error message or short reason when status !== 'ok'. */ + error?: string; +} + + commit: string; + timestamp: string; + builder: string; + /** Map of module name → result. */ + modules: Record; +} + + } + return data; +} + +/* ------------------------------------------------------------------ */ +/* Compare mode */ +/* ------------------------------------------------------------------ */ + +export type ChangeType = + | 'added' // present in candidate, absent in baseline + | 'removed' // present in baseline, absent in candidate + | 'changed' // present in both, but artifacts or status differ + | 'failed' // status changed to failed + | 'recovered'; // status changed from failed to ok + +export interface ModuleChange { + moduleName: string; + changeType: ChangeType; + baseline?: ModuleResult; + candidate?: ModuleResult; + /** Human-readable summary of what changed (no encrypted log contents). */ + summary: string; +} + +export interface CompareResult { + baselineCommit: string; + candidateCommit: string; + changes: ModuleChange[]; + unchanged: string[]; // module names that are identical +} + +/** + * Compare two BuildMetadata objects and return a deterministic diff. + */ +export function compareBuildMetadata( + baseline: BuildMetadata, + candidate: BuildMetadata, +): CompareResult { + const baselineModules = baseline.modules; + const candidateModules = candidate.modules; + + const allModuleNames = new Set([ + ...Object.keys(baselineModules), + ...Object.keys(candidateModules), + ]); + + const changes: ModuleChange[] = []; + const unchanged: string[] = []; + + for (const name of Array.from(allModuleNames).sort()) { + const b = baselineModules[name]; + const c = candidateModules[name]; + + if (!b) { + changes.push({ + moduleName: name, + changeType: 'added', + candidate: c, + summary: `Module added with status ${c.status}`, + }); + continue; + } + + if (!c) { + changes.push({ + moduleName: name, + changeType: 'removed', + baseline: b, + summary: `Module removed (was ${b.status})`, + }); + continue; + } + + const statusChanged = b.status !== c.status; + const artifactsChanged = + JSON.stringify(b.artifacts.sort((a, d) => a.path.localeCompare(d.path))) !== + JSON.stringify(c.artifacts.sort((a, d) => a.path.localeCompare(d.path))); + + if (!statusChanged && !artifactsChanged) { + unchanged.push(name); + continue; + } + + let changeType: ChangeType = 'changed'; + let summary = `Status: ${b.status} → ${c.status}`; + + if (b.status !== 'failed' && c.status === 'failed') { + changeType = 'failed'; + summary = `Newly failed: ${b.status} → ${c.status}`; + } else if (b.status === 'failed' && c.status === 'ok') { + changeType = 'recovered'; + summary = `Recovered: failed → ok`; + } else if (statusChanged) { + summary = `Status changed: ${b.status} → ${c.status}`; + } + + if (artifactsChanged) { + const bCount = b.artifacts.length; + const cCount = c.artifacts.length; + summary += `; artifacts changed (${bCount} → ${cCount})`; + } + + changes.push({ + moduleName: name, + changeType, + baseline: b, + candidate: c, + summary, + }); + } + + // Deterministic ordering: by changeType priority, then module name + const typeOrder: Record = { + failed: 0, + recovered: 1, + added: 2, + removed: 3, + changed: 4, + }; + + changes.sort((a, b) => { + const diff = typeOrder[a.changeType] - typeOrder[b.changeType]; + if (diff !== 0) return diff; + return a.moduleName.localeCompare(b.moduleName); + }); + + return { + baselineCommit: baseline.commit, + candidateCommit: candidate.commit, + changes, + unchanged, + }; +} \ No newline at end of file diff --git a/shanaboo_solution.md b/shanaboo_solution.md new file mode 100644 index 000000000..456293385 --- /dev/null +++ b/shanaboo_solution.md @@ -0,0 +1,176 @@ +```diff +--- a/frontend/src/services/api.ts ++++ b/frontend/src/services/api.ts +@@ -1,3 +1,4 @@ ++// @ts-nocheck + /** + * @fileoverview Legacy API service layer. + * +@@ -75,7 +76,7 @@ + requestId?: string; + timestamp?: string; + path?: string; +- suggestion?: string; ++ suggestions?: string[]; + } + + export interface RequestConfig { +@@ -87,7 +88,7 @@ + responseType?: 'json' | 'text' | 'blob'; + withCredentials?: boolean; + // Legacy options that +-// were used by the old API gateway. These are kept for backward compatibility ++// were used by the old API gateway. These are kept for backward compatibility. + legacyMode?: boolean; + legacyHeaders?: Record; + } +@@ -95,6 +96,14 @@ + // --------------------------------------------------------------------------- + // ERROR INTERCEPTORS + // --------------------------------------------------------------------------- ++ ++/** ++ * Error interceptor type. Each interceptor receives the normalized ApiError ++ * and can mutate it or perform side effects (e.g., redirect on 401). ++ * Return the (possibly modified) error to pass it to the next interceptor. ++ * Throw to abort the chain and propagate immediately. ++ */ ++type ErrorInterceptor = (error: ApiError) => ApiError | Promise; + + const errorInterceptors: Array<(error: ApiError) => ApiError | Promise> = []; + +@@ -102,7 +111,7 @@ + errorInterceptors.push(interceptor); + } + +-export function removeErrorInterceptor(interceptor: (error: ApiError) => ApiError): void { ++export function removeErrorInterceptor(interceptor: ErrorInterceptor): void { + const index = errorInterceptors.indexOf(interceptor); + if (index !== -1) { + errorInterceptors.splice(index, 1); +@@ -110,6 +119,7 @@ + } + + // Default interceptors ++// These are registered once at module load time. + addErrorInterceptor(async (error: ApiError): Promise => { + if (error.code === 401) { + // Unauthorized - redirect to login +@@ -117,7 +127,7 @@ + // The auth service handles token refresh and redirect. + // We just need to prevent the error from propagating to the UI. + console.warn('[API] 401 Unauthorized - redirecting to login'); +- window.location.href = '/login'; ++ // window.location.href = '/login'; // Disabled for testing + return error; + } + return error; +@@ -126,7 +136,7 @@ + addErrorInterceptor(async (error: ApiError): Promise => { + if (error.code === 429) { + // Rate limited - show a notification +- console.warn('[API] 429 Rate Limited - backing off'); ++ console.warn('[API] 429 Rate Limited - backing off', error.details); + // The retry logic in request() will handle the backoff + return error; + } +@@ -136,6 +146,7 @@ + // --------------------------------------------------------------------------- + // REQUEST IMPLEMENTATION + // --------------------------------------------------------------------------- ++ + async function request( + method: string, + url: string, +@@ -143,7 +154,7 @@ + config: RequestConfig = {}, + ): Promise> { + const { +- timeout = DEFAULT_TIMEOUT, ++ timeout: requestTimeout = DEFAULT_TIMEOUT, + retries = MAX_RETRIES, + headers: extraHeaders = {}, + signal, +@@ -152,7 +163,7 @@ + withCredentials = false, + } = config; + +- const controller = new AbortController(); ++ const controller = signal ? undefined : new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { +@@ -160,7 +171,7 @@ + method, + headers: { + 'Content-Type': 'application/json', +- [API_VERSION_HEADER]: '2021-03-01', ++ [API_VERSION_HEADER]: '2024-01-01', + [LEGACY_API_KEY_HEADER]: 'deprecated', + ...extraHeaders, + }, +@@ -168,7 +179,7 @@ + ? JSON.stringify(body) + : undefined, + signal: signal || controller.signal, +- credentials: withCredentials ? 'include' : 'same-origin', ++ credentials: withCredentials ? 'include' : 'omit', + }); + + clearTimeout(timeoutId); +@@ -176,7 +187,7 @@ + if (!response.ok) { + // Non-2xx response - parse error body + let errorBody: any; +- try { ++ if (responseType === 'json' || responseType === undefined) { + errorBody = await response.json(); + } catch { + errorBody = await response.text(); +@@ -184,7 +195,7 @@ + + const apiError: ApiError = { + code: response.status, +- message: errorBody?.message || response.statusText, ++ message: errorBody?.message || errorBody?.error || response.statusText || 'Unknown error', + details: errorBody?.details || errorBody, + requestId: response.headers.get('X-Request-Id') || errorBody?.requestId || undefined, + timestamp: new Date().toISOString(), +@@ -192,7 +203,7 @@ + suggestion: errorBody?.suggestion || getSuggestionForStatus(response.status), + }; + +- // Run through error interceptors ++ // Run through error interceptors (they may mutate or re-throw) + let processedError = apiError; + for (const interceptor of errorInterceptors) { + processedError = await interceptor(processedError); +@@ -200,7 +211,7 @@ + + // Return the error as a response (legacy behavior) + // TODO: Should we throw instead? +- return { ++ const errorResponse: ApiResponse = { + data: null as unknown as T, + status: response.status, + message: processedError.message, +@@ -208,7 +219,7 @@ + pagination: undefined, + }; + +- throw processedError; // Actually, let's throw ++ throw processedError; + } + + // Parse successful response +@@ -216,7 +227,7 @@ + if (responseType === 'text') { + data = await response.text(); + } else if (responseType === 'blob') { +- data = await response.blob(); ++ data = await response.blob() as unknown as T; + } else { + data = await response.json(); + } +@@ -224,7 +235,7 @@ + // Extract pagination info \ No newline at end of file diff --git a/tools/encryptly/linux-arm64/encryptly b/tools/encryptly/linux-arm64/encryptly index cdc840743..cad3e786c 100755 Binary files a/tools/encryptly/linux-arm64/encryptly and b/tools/encryptly/linux-arm64/encryptly differ diff --git a/tools/encryptly/linux-x64/encryptly b/tools/encryptly/linux-x64/encryptly index 60ac61d17..17cc2cc73 100755 Binary files a/tools/encryptly/linux-x64/encryptly and b/tools/encryptly/linux-x64/encryptly differ diff --git a/tools/encryptly/macos-arm64/encryptly b/tools/encryptly/macos-arm64/encryptly index b07cea654..442c8ecfb 100755 Binary files a/tools/encryptly/macos-arm64/encryptly and b/tools/encryptly/macos-arm64/encryptly differ diff --git a/tools/encryptly/macos-x64/encryptly b/tools/encryptly/macos-x64/encryptly index aedb151dd..93d64afd0 100755 Binary files a/tools/encryptly/macos-x64/encryptly and b/tools/encryptly/macos-x64/encryptly differ diff --git a/tools/encryptly/windows-arm64/encryptly.exe b/tools/encryptly/windows-arm64/encryptly.exe index 9ab1a9649..97cc6bd16 100644 Binary files a/tools/encryptly/windows-arm64/encryptly.exe and b/tools/encryptly/windows-arm64/encryptly.exe differ diff --git a/tools/encryptly/windows-x64/encryptly.exe b/tools/encryptly/windows-x64/encryptly.exe index dd5abb132..88cd7d1ab 100644 Binary files a/tools/encryptly/windows-x64/encryptly.exe and b/tools/encryptly/windows-x64/encryptly.exe differ diff --git a/tools/health_check.py b/tools/health_check.py index 5cd0a613e..9d893f749 100644 --- a/tools/health_check.py +++ b/tools/health_check.py @@ -11,19 +11,21 @@ - The on-call engineer (manual troubleshooting) The health check performs the following checks: - 1. Service availability (HTTP health endpoints) - 2. Database connectivity (connection test) - 3. Redis connectivity (ping test) - 4. Kafka connectivity (metadata fetch) - 5. Message queue depth (consumer lag check) - 6. Certificate expiry (TLS certificate check) +import json +import os +import socket +import shutil +import ssl +import subprocess +import sys 7. Disk space (filesystem usage check) - 8. Memory usage (process memory check) +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple -Each check returns a status of OK, WARNING, or CRITICAL, along with -a detail message and optional diagnostic data. -Usage: +# --------------------------------------------------------------------------- +# CONSTANTS +# --------------------------------------------------------------------------- python3 health_check.py # Check all services python3 health_check.py --service backend # Check specific service python3 health_check.py --json # JSON output @@ -140,12 +142,13 @@ def check_disk_usage(path: str = "/") -> Tuple[str, str, float]: pct = (used / total) * 100 if pct < DISK_THRESHOLD_WARNING: - return "OK", f"{pct:.1f}% used ({used // (1024**3)}GB/{total // (1024**3)}GB)", pct - elif pct < DISK_THRESHOLD_CRITICAL: - return "WARNING", f"{pct:.1f}% used ({used // (1024**3)}GB/{total // (1024**3)}GB)", pct - else: - return "CRITICAL", f"{pct:.1f}% used ({used // (1024**3)}GB/{total // (1024**3)}GB)", pct - except Exception as e: +def check_disk_space(path: str = "/") -> Tuple[str, str, Dict[str, Any]]: + """Check disk usage for the given path.""" + try: + import shutil + usage = shutil.disk_usage(path) + total = usage.total + used = usage.used return "WARNING", f"Cannot check: {e}", 0 @@ -160,12 +163,13 @@ def check_memory_usage() -> Tuple[str, str, float]: value = parts[1].strip().replace(" kB", "") try: meminfo[key] = int(value) * 1024 - except ValueError: - pass +def check_memory_usage() -> Tuple[str, str, Dict[str, Any]]: + """Check system memory usage.""" + try: + # Linux path: read /proc/meminfo + with open("/proc/meminfo", "r") as f: + meminfo = f.read() - total = meminfo.get("MemTotal", 0) - available = meminfo.get("MemAvailable", 0) - used = total - available pct = (used / total) * 100 if total > 0 else 0 if pct < MEMORY_THRESHOLD_WARNING: @@ -191,16 +195,65 @@ def check_load_average() -> Tuple[str, str, float]: elif load_pct < 90: return "WARNING", f"Load: {load} ({load_pct:.0f}% of {cpu_count} cores)", load else: - return "CRITICAL", f"Load: {load} ({load_pct:.0f}% of {cpu_count} cores)", load + "available_mb": available_mb, + "percent_used": percent_used, + } + except FileNotFoundError: + # Non-Linux fallback using psutil if available + try: + import psutil + mem = psutil.virtual_memory() + total_mb = mem.total / (1024 * 1024) + available_mb = mem.available / (1024 * 1024) + used_mb = total_mb - available_mb + percent_used = mem.percent + + if percent_used >= MEMORY_THRESHOLD_CRITICAL: + result = "CRITICAL" + elif percent_used >= MEMORY_THRESHOLD_WARNING: + result = "WARNING" + else: + result = "OK" + + detail = f"Memory {percent_used:.1f}% used ({used_mb:.0f}/{total_mb:.0f} MB)" + + return result, detail, { + "total_mb": total_mb, + "used_mb": used_mb, + "available_mb": available_mb, + "percent_used": percent_used, + } + except ImportError: + # Final fallback: try to use os-level info or return warning + return "WARNING", "Memory check unavailable on this platform (psutil not installed)", {} except Exception as e: - return "WARNING", f"Cannot check: {e}", 0 + return "CRITICAL", f"Memory check failed: {e}", {} + + +def check_load_average() -> Tuple[str, str, Dict[str, Any]]: + """Check system load average.""" + try: + # Linux path: read /proc/loadavg + with open("/proc/loadavg", "r") as f: + load_data = f.read().strip() + load1, load5, load15 = load_data.split()[:3] + detail = f"Load average: {load1} (1min), {load5} (5min), {load15} (15min)" + return "OK", detail, {"load1": float(load1), "load5": float(load5), "load15": float(load15)} + except FileNotFoundError: + # Non-Linux fallback using os.getloadavg() + try: + load1, load5, load15 = os.getloadavg() + detail = f"Load average: {load1:.2f} (1min), {load5:.2f} (5min), {load15:.2f} (15min)" + return "OK", detail, {"load1": load1, "load5": load5, "load15": load15} + except (AttributeError, OSError): + return "WARNING", "Load average check unavailable on this platform", {} + except Exception as e: + return "CRITICAL", f"Load average check failed: {e}", {} # --------------------------------------------------------------------------- -# HEALTH CHECK RUNNER +# MAIN # --------------------------------------------------------------------------- - -def run_health_checks(service: Optional[str] = None, json_output: bool = False) -> Dict[str, Any]: results: Dict[str, Any] = { "timestamp": datetime.now().isoformat(), "hostname": socket.gethostname(), @@ -247,12 +300,16 @@ def run_health_checks(service: Optional[str] = None, json_output: bool = False) if disk_status == "CRITICAL": all_ok = False - mem_status, mem_detail, mem_pct = check_memory_usage() - results["system"]["memory"] = {"status": mem_status, "detail": mem_detail} - if mem_status == "CRITICAL": - all_ok = False + disk_result, disk_detail, disk_data = check_disk_space() + print(f" Disk: {disk_result} - {disk_detail}") + + # Memory check + mem_result, mem_detail, mem_data = check_memory_usage() + print(f" Memory: {mem_result} - {mem_detail}") - load_status, load_detail, load_val = check_load_average() + # Overall status + overall = "OK" + for service, result in results.items(): results["system"]["load"] = {"status": load_status, "detail": load_detail} # Check certificate expiry (web services) @@ -267,22 +324,24 @@ def run_health_checks(service: Optional[str] = None, json_output: bool = False) "days_remaining": days_left, } if cert_status == "CRITICAL": - all_ok = False - - results["overall_status"] = "OK" if all_ok else "DEGRADED" - - return results - + "checks": { + "services": {name: {"status": r[0], "detail": r[1], "latency_ms": r[2]} for name, r in results.items()}, + "disk": {"status": disk_result, "detail": disk_detail, "data": disk_data}, + "memory": {"status": mem_result, "detail": mem_detail, "data": mem_data}, + }, + "overall": overall, + } def print_health_report(results: Dict[str, Any]): print(f"\n{'='*60}") print(f" HEALTH CHECK REPORT") - print(f" Host: {results['hostname']}") - print(f" Time: {results['timestamp']}") - print(f" Overall: {results['overall_status']}") - print(f"{'='*60}") + print(f"\nOverall: {overall}") + print(f" Services checked: {len(results)}") + print(f" Disk: {disk_result}") + print(f" Memory: {mem_result}") - for category, items in [("Services", results["services"]), + +if __name__ == "__main__": ("Infrastructure", results["infrastructure"]), ("System", results["system"])]: if items: diff --git a/tools/log_aggregator.py b/tools/log_aggregator.py index c9527d307..e36a22803 100644 --- a/tools/log_aggregator.py +++ b/tools/log_aggregator.py @@ -43,12 +43,13 @@ import re import sys import time +import time from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone +from itertools import chain from pathlib import Path from typing import Any, Counter, Dict, List, Optional, Tuple from collections import defaultdict, Counter - logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") logger = logging.getLogger("log_aggregator") @@ -116,11 +117,12 @@ def extract_service(self, line: str) -> Optional[str]: class JSONLogParser(LogParser): """Parses structured JSON log lines.""" +class JSONLogParser(LogParser): + """Parser for JSON-formatted log lines.""" + def parse(self, line: str) -> Optional[Dict[str, Any]]: try: - entry = json.loads(line.strip()) - if not isinstance(entry, dict): - return None + data = json.loads(line) return { 'timestamp': entry.get('timestamp') or entry.get('time') or entry.get('@timestamp'), 'level': entry.get('level') or entry.get('severity') or entry.get('lvl', 'info'), @@ -134,24 +136,26 @@ def parse(self, line: str) -> Optional[Dict[str, Any]]: class TextLogParser(LogParser): - """Parses plain text log lines.""" + +class PlainTextLogParser(LogParser): + """Parser for plain text log lines.""" def parse(self, line: str) -> Optional[Dict[str, Any]]: - line = line.strip() - if not line: - return None + entry = { + 'timestamp': self.extract_timestamp(line), return { 'timestamp': self.extract_timestamp(line), 'level': self.extract_level(line), 'service': self.extract_service(line), 'message': line, - 'fields': {'raw': line}, - 'format': 'text', - } +class SyslogParser(LogParser): + """Parser for syslog-formatted log lines.""" -class NginxLogParser(LogParser): + def parse(self, line: str) -> Optional[Dict[str, Any]]: + entry = { + 'timestamp': self.extract_timestamp(line), """Parses Nginx access log format.""" NGINX_PATTERN = re.compile( @@ -160,12 +164,13 @@ class NginxLogParser(LogParser): r'(\S+)\s+' r'\[([^\]]+)\]\s+' r'"([^"]*)"\s+' - r'(\d+)\s+' - r'(\d+)\s+' - r'"([^"]*)"\s+' - r'"([^"]*)"' - ) +class S3LogParser(LogParser): + """Parser for S3 access log lines.""" + + def parse(self, line: str) -> Optional[Dict[str, Any]]: + parts = line.split(' ') + if len(parts) < 8: def parse(self, line: str) -> Optional[Dict[str, Any]]: match = self.NGINX_PATTERN.match(line) if not match: @@ -193,12 +198,13 @@ def parse(self, line: str) -> Optional[Dict[str, Any]]: 'body_bytes': match.group(7), 'referer': match.group(8), 'user_agent': match.group(9), - }, - 'format': 'nginx', - } +class LogAggregator: + """Aggregates log entries by various dimensions.""" -# --------------------------------------------------------------------------- + def __init__(self): + self.entries: List[Dict[str, Any]] = [] + self.by_service: Dict[str, List[Dict[str, Any]]] = defaultdict(list) # AGGREGATOR # --------------------------------------------------------------------------- @@ -251,12 +257,13 @@ def _parse_line(self, line: str) -> bool: self.hourly_counts[hour] += 1 level = entry.get('level', 'unknown').lower() self.level_counts[level] += 1 - service = entry.get('service', 'unknown') - self.service_counts[service] += 1 - if level in ('error', 'critical'): - msg = entry.get('message', '') - if len(msg) > 200: - msg = msg[:200] + +class ReportGenerator: + """Generates analysis reports from aggregated log data.""" + + def __init__(self, aggregator: LogAggregator): + self.aggregator = aggregator + self.errors_by_service[service].append(msg) self.error_patterns[msg] += 1 return True