diff --git a/asyncapi.diagnostics.patch.yaml b/asyncapi.diagnostics.patch.yaml new file mode 100644 index 0000000..2bd2fb4 --- /dev/null +++ b/asyncapi.diagnostics.patch.yaml @@ -0,0 +1,33 @@ +# Additive AsyncAPI patch fragment for diagnostics bundles. +# Merge this file into asyncapi.yaml at build time using @asyncapi/bundler. +# All channels use Kafka with the same broker configuration as the base spec. + +channels: + srcos.v2.diagnostics.events: + description: Events emitted when a DiagnosticsBundle is recorded. + bindings: + kafka: + topic: srcos.v2.diagnostics.events + partitions: 3 + replicas: 3 + topicConfiguration: + retention.ms: 2592000000 + cleanup.policy: delete + publish: + operationId: publishDiagnosticsBundleEvent + summary: Publish a diagnostics bundle domain event + description: Publishes an event whenever a DiagnosticsBundle is recorded. + message: + name: DiagnosticsBundleEvent + title: Diagnostics Bundle Domain Event + summary: A change event for a DiagnosticsBundle. + description: | + The `eventType` in the payload envelope is `DiagnosticsBundleRecorded`. + The payload contains the full DiagnosticsBundle object. + contentType: application/json + payload: { $ref: './schemas/EventEnvelope.json' } + bindings: + kafka: + key: + type: string + description: The `objectId` URN of the DiagnosticsBundle. diff --git a/docs/adr/ADR-diagnostics-bundle-evidence-artifact.md b/docs/adr/ADR-diagnostics-bundle-evidence-artifact.md new file mode 100644 index 0000000..570ca29 --- /dev/null +++ b/docs/adr/ADR-diagnostics-bundle-evidence-artifact.md @@ -0,0 +1,61 @@ +# ADR — Diagnostics bundle as evidence artifact + +**Date:** 2026-04-16 +**Status:** `Proposed` + +--- + +## Context + +SourceOS and SociOS already define machine-readable contracts for workloads, provenance, policies, and agent-session telemetry. What remains under-specified is the host-diagnostics capture artifact itself: a bounded bundle of timed probes, artifact hashes, and redaction posture that can be collected locally, diffed later, and exported safely when needed. + +Without a canonical diagnostics object, downstream repos risk inventing incompatible formats for: + +- timed command manifests +- privacy-preserving host identifiers +- raw-vs-share artifact separation +- bundle-level hashing and signing +- support and incident handoff between runtime, operator, and automation layers + +## Decision + +Introduce `DiagnosticsBundle` as a first-class execution/provenance contract in `SourceOS-Linux/sourceos-spec`, with supporting schemas for `DiagnosticsProbeRecord` and `DiagnosticsRedactionPolicy`. + +The bundle contract standardizes: + +1. bundle identity via `urn:srcos:diag-bundle:` +2. probe-level timing, exit status, and artifact digests +3. declared redaction posture for raw and share-safe bundle forms +4. optional bundle-level integrity hashes and signatures +5. optional linkage upward to `RunRecord` URNs for runtime or workflow correlation + +## Alternatives considered + +| Alternative | Reason not chosen | +|-------------|------------------| +| Keep diagnostics as an implementation-only script format in a runtime repo | Would create a second implicit schema authority and make provenance, conformance, and interop harder. | +| Reuse `RunRecord` directly for diagnostics bundles | `RunRecord` captures workload execution, not a custody-oriented bundle of timed host probes and redaction metadata. | +| Put diagnostics contracts only in SociOS runtime repos | The typed contract layer belongs in `sourceos-spec`; runtime repos should consume, not redefine, the object semantics. | + +## Consequences + +### Positive + +- creates a canonical evidence object for host diagnostics capture +- makes raw/share separation and masking posture explicit instead of ad hoc +- gives downstream runtime and packaging repos a stable object to produce and validate +- preserves alignment with existing execution/provenance and event-envelope patterns + +### Negative / constraints + +- this initial tranche is additive and intentionally narrow +- it does not yet update every catalog and semantic index file in the repo +- it does not yet define the runtime capability or immutable-image packaging surface + +## References + +- `schemas/DiagnosticsBundle.json` +- `schemas/DiagnosticsProbeRecord.json` +- `schemas/DiagnosticsRedactionPolicy.json` +- `openapi.diagnostics.patch.yaml` +- `asyncapi.diagnostics.patch.yaml` diff --git a/examples/diagnosticsbundle.json b/examples/diagnosticsbundle.json new file mode 100644 index 0000000..0559cc0 --- /dev/null +++ b/examples/diagnosticsbundle.json @@ -0,0 +1,100 @@ +{ + "id": "urn:srcos:diag-bundle:hosttag-20260416t034500z", + "type": "DiagnosticsBundle", + "specVersion": "2.0.0", + "generatedAt": "2026-04-16T03:45:00Z", + "hostTag": "a1b2c3d4e5f6", + "bootSelector": "-0", + "sinceWindow": "24 hours ago", + "tiersEnabled": [ + 0, + 1 + ], + "capturedBy": { + "toolName": "socios-diagkit", + "toolVersion": "0.1.0" + }, + "platform": { + "osRelease": "Fedora Linux 42.20260415.0 (Silverblue)", + "kernel": "6.13.8-200.fc42.x86_64" + }, + "probeRecords": [ + { + "name": "journal_boot", + "tier": 0, + "timeoutSeconds": 25, + "command": "journalctl --boot -0 --no-pager -o short-iso", + "exitCode": 0, + "timeoutHit": false, + "durationMs": 412, + "stdout": { + "path": "raw/0/journal_boot.out", + "bytes": 18442, + "sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "stderr": { + "path": "raw/0/journal_boot.err", + "bytes": 0, + "sha256": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "rssMaxKb": 2048 + }, + { + "name": "ss_listen", + "tier": 0, + "timeoutSeconds": 5, + "command": "ss -tulpn", + "exitCode": 0, + "timeoutHit": false, + "durationMs": 37, + "stdout": { + "path": "raw/0/ss_listen.out", + "bytes": 2271, + "sha256": "3333333333333333333333333333333333333333333333333333333333333333" + }, + "stderr": { + "path": "raw/0/ss_listen.err", + "bytes": 0, + "sha256": "4444444444444444444444444444444444444444444444444444444444444444" + }, + "rssMaxKb": 512 + } + ], + "summary": { + "probeCount": 2, + "failedCount": 0, + "timeoutCount": 0 + }, + "artifacts": { + "rawTreeSha256": "5555555555555555555555555555555555555555555555555555555555555555", + "sharedTreeSha256": "6666666666666666666666666666666666666666666666666666666666666666", + "tarballSha256": "7777777777777777777777777777777777777777777777777777777777777777" + }, + "redactionPolicy": { + "mode": "raw-and-share", + "rawArtifactRetained": true, + "sharedArtifactGenerated": true, + "deterministicMasking": true, + "maskedClasses": [ + "authorizationBearer", + "cookie", + "apiKey", + "token", + "email", + "privateIp", + "macAddress" + ], + "privateIpMasked": true, + "publicIpMasked": false, + "macAddressMasked": true, + "notes": "Default share-safe policy for operator export; public IPs remain visible for network triage unless explicitly overridden." + }, + "relatedRunRefs": [ + "urn:srcos:run:diag-capture-20260416" + ], + "integrity": { + "manifestSha256": "8888888888888888888888888888888888888888888888888888888888888888", + "bundleHash": "9999999999999999999999999999999999999999999999999999999999999999", + "signature": "sigstore:rekor:example-entry-0001" + } +} diff --git a/examples/diagnosticsproberecord.json b/examples/diagnosticsproberecord.json new file mode 100644 index 0000000..50592a8 --- /dev/null +++ b/examples/diagnosticsproberecord.json @@ -0,0 +1,20 @@ +{ + "name": "journal_boot", + "tier": 0, + "timeoutSeconds": 25, + "command": "journalctl --boot -0 --no-pager -o short-iso", + "exitCode": 0, + "timeoutHit": false, + "durationMs": 412, + "stdout": { + "path": "raw/0/journal_boot.out", + "bytes": 18442, + "sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "stderr": { + "path": "raw/0/journal_boot.err", + "bytes": 0, + "sha256": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "rssMaxKb": 2048 +} diff --git a/examples/diagnosticsredactionpolicy.json b/examples/diagnosticsredactionpolicy.json new file mode 100644 index 0000000..bb63a9a --- /dev/null +++ b/examples/diagnosticsredactionpolicy.json @@ -0,0 +1,19 @@ +{ + "mode": "raw-and-share", + "rawArtifactRetained": true, + "sharedArtifactGenerated": true, + "deterministicMasking": true, + "maskedClasses": [ + "authorizationBearer", + "cookie", + "apiKey", + "token", + "email", + "privateIp", + "macAddress" + ], + "privateIpMasked": true, + "publicIpMasked": false, + "macAddressMasked": true, + "notes": "Default share-safe policy for operator export; public IPs remain visible for network triage unless explicitly overridden." +} diff --git a/openapi.diagnostics.patch.yaml b/openapi.diagnostics.patch.yaml new file mode 100644 index 0000000..711a89d --- /dev/null +++ b/openapi.diagnostics.patch.yaml @@ -0,0 +1,31 @@ +# Additive OpenAPI patch fragment for diagnostics bundles. +# Merge this file into openapi.yaml at build time. +# All operations require bearerAuth (inherited from the base spec security requirement). + +tags: + - name: Diagnostics + description: Governed host diagnostics evidence bundles and their supporting metadata + +paths: + /v2/diagnostics/bundles: + post: + operationId: recordDiagnosticsBundle + summary: Record a DiagnosticsBundle + description: Records a governed host diagnostics evidence bundle containing timed probe metadata, artifact hashes, and declared redaction posture. Idempotent on `id`. + tags: [Diagnostics] + requestBody: + required: true + content: + application/json: + schema: { $ref: './schemas/DiagnosticsBundle.json' } + responses: + '200': + description: DiagnosticsBundle recorded successfully. + '400': + description: Request body is malformed JSON. + '401': + description: Missing or invalid authentication token. + '403': + description: Authenticated subject is not permitted to record diagnostics bundles. + '422': + description: Request body is valid JSON but fails schema validation. diff --git a/schemas/DiagnosticsBundle.json b/schemas/DiagnosticsBundle.json new file mode 100644 index 0000000..0cbe18d --- /dev/null +++ b/schemas/DiagnosticsBundle.json @@ -0,0 +1,217 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/v2/DiagnosticsBundle.json", + "title": "DiagnosticsBundle", + "description": "A governed host diagnostics evidence bundle capturing timed probes, artifact hashes, redaction posture, and bundle-level integrity metadata.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "specVersion", + "generatedAt", + "hostTag", + "bootSelector", + "sinceWindow", + "tiersEnabled", + "capturedBy", + "probeRecords", + "summary", + "redactionPolicy" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^urn:srcos:diag-bundle:", + "description": "Stable URN identifier. Pattern: urn:srcos:diag-bundle:" + }, + "type": { + "const": "DiagnosticsBundle", + "description": "Discriminator constant — always \"DiagnosticsBundle\"." + }, + "specVersion": { + "type": "string", + "description": "Spec version of this document, e.g. \"2.0.0\"." + }, + "generatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 date-time when the diagnostics bundle was finalized." + }, + "hostTag": { + "type": "string", + "minLength": 1, + "description": "Stable but privacy-preserving host label used to correlate bundles from the same node without disclosing the raw hostname." + }, + "bootSelector": { + "type": "string", + "minLength": 1, + "description": "Boot selector or boot-relative capture target used when exporting journald and kernel context, such as \"-0\" for the current boot." + }, + "sinceWindow": { + "type": "string", + "minLength": 1, + "description": "Human-readable time window used for log-oriented capture surfaces, such as \"24 hours ago\"." + }, + "tiersEnabled": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "integer", + "minimum": 0 + }, + "description": "Ordered list of capture tiers enabled when this bundle was collected." + }, + "capturedBy": { + "type": "object", + "additionalProperties": false, + "required": [ + "toolName", + "toolVersion" + ], + "properties": { + "toolName": { + "type": "string", + "minLength": 1, + "description": "Name of the collector or orchestration capability that produced the bundle." + }, + "toolVersion": { + "type": "string", + "minLength": 1, + "description": "Collector or orchestration capability version used to produce the bundle." + } + }, + "description": "Collector identity and version metadata for this bundle." + }, + "platform": { + "type": "object", + "additionalProperties": false, + "properties": { + "osRelease": { + "type": [ + "string", + "null" + ], + "description": "Human-readable operating-system release identifier captured at bundle time, if recorded." + }, + "kernel": { + "type": [ + "string", + "null" + ], + "description": "Kernel release string captured at bundle time, if recorded." + } + }, + "description": "Optional host platform summary duplicated from the raw capture to support quick indexing and filtering." + }, + "probeRecords": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "DiagnosticsProbeRecord.json" + }, + "description": "Manifest records for each timed probe or command executed while assembling the bundle." + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "probeCount", + "failedCount", + "timeoutCount" + ], + "properties": { + "probeCount": { + "type": "integer", + "minimum": 0, + "description": "Total number of probe records in this bundle." + }, + "failedCount": { + "type": "integer", + "minimum": 0, + "description": "Number of probes that returned a non-zero exit code." + }, + "timeoutCount": { + "type": "integer", + "minimum": 0, + "description": "Number of probes that hit their timeout budget." + } + }, + "description": "Derived bundle-level counts useful for quick health checks and indexing." + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "properties": { + "rawTreeSha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "SHA-256 digest summarizing the retained raw artifact tree, if computed." + }, + "sharedTreeSha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "SHA-256 digest summarizing the redacted share-safe artifact tree, if generated." + }, + "tarballSha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "SHA-256 digest of a packaged diagnostics tarball, if produced." + } + }, + "description": "Optional bundle-level artifact digests for raw, shared, and packaged outputs." + }, + "redactionPolicy": { + "$ref": "DiagnosticsRedactionPolicy.json", + "description": "Declared redaction posture used when preparing raw and share-safe forms of this bundle." + }, + "relatedRunRefs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^urn:srcos:run:" + }, + "description": "Optional RunRecord URNs associated with the production, transfer, or analysis of this diagnostics bundle." + }, + "integrity": { + "type": "object", + "additionalProperties": false, + "properties": { + "manifestSha256": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "SHA-256 digest of the bundle manifest itself, if separately computed." + }, + "bundleHash": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "Top-level digest representing the full bundle payload, if computed." + }, + "signature": { + "type": [ + "string", + "null" + ], + "description": "Optional cryptographic signature covering the bundle or its top-level manifest." + } + }, + "description": "Optional integrity envelope for the bundle-level manifest and packaged artifacts." + } + } +} diff --git a/schemas/DiagnosticsProbeRecord.json b/schemas/DiagnosticsProbeRecord.json new file mode 100644 index 0000000..9843515 --- /dev/null +++ b/schemas/DiagnosticsProbeRecord.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/v2/DiagnosticsProbeRecord.json", + "title": "DiagnosticsProbeRecord", + "description": "A single timed command or probe execution captured inside a DiagnosticsBundle manifest.", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "tier", + "timeoutSeconds", + "command", + "exitCode", + "timeoutHit", + "durationMs", + "stdout", + "stderr" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Stable probe name within the diagnostics bundle." + }, + "tier": { + "type": "integer", + "minimum": 0, + "description": "Capture tier for this probe, where 0 is the lowest-risk baseline and higher numbers are progressively more invasive." + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 0, + "description": "Maximum allowed runtime budget for this probe, in seconds." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Exact shell command or equivalent invocation executed for this probe." + }, + "exitCode": { + "type": "integer", + "description": "Process exit code returned by the probe." + }, + "timeoutHit": { + "type": "boolean", + "description": "Whether the probe exceeded its timeout budget and was terminated or otherwise classified as timed out." + }, + "durationMs": { + "type": "integer", + "minimum": 0, + "description": "Observed wall-clock runtime for this probe, in milliseconds." + }, + "stdout": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "bytes", + "sha256" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Relative path of the captured stdout artifact within the bundle." + }, + "bytes": { + "type": "integer", + "minimum": 0, + "description": "Byte size of the stdout artifact." + }, + "sha256": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "SHA-256 digest of the stdout artifact." + } + }, + "description": "Integrity and location metadata for the probe's stdout artifact." + }, + "stderr": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "bytes", + "sha256" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Relative path of the captured stderr artifact within the bundle." + }, + "bytes": { + "type": "integer", + "minimum": 0, + "description": "Byte size of the stderr artifact." + }, + "sha256": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$", + "description": "SHA-256 digest of the stderr artifact." + } + }, + "description": "Integrity and location metadata for the probe's stderr artifact." + }, + "rssMaxKb": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "Maximum resident set size recorded for the probe in kibibytes, if the runtime collector was able to observe it." + } + } +} diff --git a/schemas/DiagnosticsRedactionPolicy.json b/schemas/DiagnosticsRedactionPolicy.json new file mode 100644 index 0000000..51158a8 --- /dev/null +++ b/schemas/DiagnosticsRedactionPolicy.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/v2/DiagnosticsRedactionPolicy.json", + "title": "DiagnosticsRedactionPolicy", + "description": "Declares how a DiagnosticsBundle was transformed from raw local artifacts into a share-safe artifact set.", + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "rawArtifactRetained", + "sharedArtifactGenerated", + "deterministicMasking", + "maskedClasses", + "privateIpMasked", + "publicIpMasked", + "macAddressMasked" + ], + "properties": { + "mode": { + "type": "string", + "enum": [ + "none", + "share-only", + "raw-and-share" + ], + "description": "Overall redaction mode used for the bundle capture and export flow." + }, + "rawArtifactRetained": { + "type": "boolean", + "description": "Whether the unredacted raw artifact set was retained locally after capture." + }, + "sharedArtifactGenerated": { + "type": "boolean", + "description": "Whether a redacted share-safe artifact set was generated from the raw bundle." + }, + "deterministicMasking": { + "type": "boolean", + "description": "Whether masking was deterministic for repeated occurrences of the same secret or identifier within the bundle." + }, + "maskedClasses": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "authorizationBearer", + "cookie", + "apiKey", + "token", + "email", + "privateIp", + "publicIp", + "macAddress" + ] + }, + "description": "Sensitive value classes the redaction pass was configured to mask." + }, + "privateIpMasked": { + "type": "boolean", + "description": "Whether RFC1918 and similar local/private IP addresses were masked in the shared artifact set." + }, + "publicIpMasked": { + "type": "boolean", + "description": "Whether public IP addresses were masked in the shared artifact set." + }, + "macAddressMasked": { + "type": "boolean", + "description": "Whether MAC addresses were masked in the shared artifact set." + }, + "notes": { + "type": [ + "string", + "null" + ], + "description": "Optional operator note describing any bundle-specific masking deviations or caveats." + } + } +}