feat(cli): openfilter emit-schema (FILTER-442)#87
Conversation
# 📦 Pull Request ## 📋 What does this PR do? Adds the `openfilter emit-schema <module>[:Class]` CLI subcommand — the build-time producer half of the FC-3 schema-transport contract. The command imports a filter module, locates its `FilterConfigBase` subclass, and writes the emitted JSON Schema (draft 2020-12) to stdout (default) or `-o <path>`. Filter Dockerfiles invoke this at build time; the resulting JSON is pushed as a sibling OCI artifact (`application/vnd.openfilter.schema+json`) and the image manifest is stamped with a `com.plainsight.openfilter.schema` label carrying the artifact's sha256 digest. Specifically: - New `openfilter/cli/cmd_emit_schema.py` — argparse-driven subcommand with `module` / `module:Class` resolution, automatic candidate detection (errors out cleanly when a module declares 0 or 2+ `FilterConfigBase` subclasses), `--include-managed` opt-in to surface platform-managed fields, and `-o <path>` output redirection. - Tweak the `openfilter` CLI dispatcher (`openfilter/cli/__main__.py`) to translate `cmd-with-hyphens` to `cmd_with_hyphens` so subcommands can use their canonical hyphenated spellings without breaking the underscore-named Python handler convention. - 5 unit tests in `tests/test_emit_schema_cli.py` covering stdout output, `-o <path>`, `--include-managed`, single-class auto-pick, and unknown-class failure. The wiring of the sibling-artifact upload + label-stamping into `cloudbuild-cascade.yaml` is tracked as a sub-step of [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442); it depends on PLAT-side FC-5 ingest and lands separately. --- ## 🔍 Why is this needed? [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441) landed `FilterConfigBase.emit_schema()` — the in-process serializer that produces each filter's JSON Schema. This PR is the CLI wrapper Dockerfiles invoke at build time so the artifact can ride alongside the image without a Python-runtime hop in the platform's ingest path. Once both are in place, the transport contract closes: filter CI emits the schema, pushes it as a sibling OCI artifact, and stamps the image manifest's `com.plainsight.openfilter.schema` label with the sha256 digest. The platform fetches the artifact by digest from the same registry without pulling the filter image's layers. Integrity is transitively protected by whatever trust we already place in the registry serving the image bytes — no separate key infrastructure needed. (Cosign-signed attestations were considered for the transport but deferred to a future-driver-only ticket — see [FILTER-448](https://plainsight-ai.atlassian.net/browse/FILTER-448), closed Won't Do — the marginal threat surface didn't justify standing up KMS for the first internal use.) --- ## 🧪 How was it tested? 5 unit tests in `tests/test_emit_schema_cli.py`: - `test_emit_schema_cli_stdout` — `module:Class` spec, stdout output, `$schema` keyword + managed-field stripping - `test_emit_schema_cli_writes_file` — `-o <path>` redirection - `test_emit_schema_cli_include_managed_surfaces_overrides` — `--include-managed` round-trips FC-2 markers - `test_emit_schema_cli_auto_picks_single_class` — module-only spec finds the lone `FilterConfigBase` subclass - `test_emit_schema_cli_fails_on_unknown_class` — typo'd class name errors out cleanly ``` $ uv run --with pytest pytest tests/test_emit_schema_cli.py -q ..... [100%] 5 passed in 1.79s ``` Combined with [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441)'s config-side suite (already merged in #86, 46 tests), the full FC-1 / FC-1.5 / FC-2 / FC-3-emission contract has 51 tests exercising it end-to-end. --- ## 🔗 Related Issues - [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) — this ticket - [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441) — `FilterConfigBase.emit_schema()` (merged in #86) - [FILTER-440](https://plainsight-ai.atlassian.net/browse/FILTER-440) — Declarative Configuration Contract (parent epic) - [FILTER-448](https://plainsight-ai.atlassian.net/browse/FILTER-448) — cosign / KMS signing (closed Won't Do; revisit on concrete compliance/multi-tenancy driver) --- ## 🖼️ Screenshots or Logs (if applicable) ``` $ openfilter emit-schema my_filter.filter --include-managed { "title": "MyFilterConfig", "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "sources": { "items": {"format": "video-source", "type": "string"}, "type": "array", "x-openfilter-managed": true, "x-openfilter-resolve": "orchestrator-generated" }, "confidence_threshold": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 }, ... } } ``` --- ## ✅ Checklist - [x] I have read and agreed to the terms of the [LICENSE](../LICENSE) - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide - [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style) - [x] I have signed all commits in compliance with the DCO (`git commit -s`) - [x] I have added or updated **tests** as needed - [x] I have added or updated **documentation** as needed (`docs/declarative-config.md`'s "Schema emission" section, landed with FILTER-441 in #86, already describes this CLI's surface) Signed-off-by: stwilt <swilt@plainsight.ai>
lucasmundim
left a comment
There was a problem hiding this comment.
LGTM. Clean dispatcher tweak, correct use of the FilterConfigBase.emit_schema(include_managed=...) API from #86, and a reasonable test set. A few non-blocking notes for follow-up:
- Non-atomic write to
-o <path>:cmd_emit_schema.pywrites directly withopen(path, "w"). If this ever feeds OCI artifact upload at build time, atempfile.NamedTemporaryFileon the same filesystem +os.replace()would avoid leaving a partial file on interruption. - Unwrapped
importlib.import_modulein_resolve_config_class: every other failure branch raises a cleanSystemExit(...), but a bad module path falls through as a rawModuleNotFoundErrortraceback. Minor consistency nit. - Dead
setdefaultcalls fortitleand$schemain the CLI: both keys are already injected by pydantic andFilterConfigBase.emit_schema()respectively, so the CLI'ssetdefaultcalls are no-ops and can be removed. - Auto-detect edge cases worth keeping in mind: module-level class aliases (
Alias = MyConfig) would be yielded twice byinspect.getmembers, and package re-exports (__init__.pyre-exporting a class defined in a submodule) would fail the__module__check. Neither pattern is used in the repo today — flagging in case third-party filters hit them.
shingonoide
left a comment
There was a problem hiding this comment.
Echoing Lucas's APPROVE — adding one finding worth picking up alongside his follow-ups, plus a test-coverage note.
LOG_LEVEL typo crashes the entire openfilter CLI at import time (cmd_emit_schema.py:25)
logger.setLevel(int(getattr(logging, (os.getenv("LOG_LEVEL") or "INFO").upper())))getattr(logging, "VERBOSE") (or any other typo / unsupported level) raises AttributeError at module import. Because openfilter/cli/__main__.py imports cmd_emit_schema unconditionally, a bad LOG_LEVEL in the build environment doesn't just break emit-schema — it breaks openfilter run, openfilter info, and any future subcommand the dispatcher tries to load. Suggested fix:
logger.setLevel(getattr(logging, (os.getenv("LOG_LEVEL") or "INFO").upper(), logging.INFO))(The int(...) cast is also unneeded — logging.INFO is already an int.)
Test coverage gaps tied to the import paths
The 5 tests cover happy paths well; two negative-path gaps worth filling alongside Lucas's import_module wrap:
- No test for
ModuleNotFoundError(e.g.["emit-schema", "no.such.module"]) — would lock in his suggested wrap. test_emit_schema_cli_fails_on_unknown_classexercisesgetattr(...) is None, but not theinspect.isclass(cls) and not issubclass(cls, FilterConfigBase)branch (cmd_emit_schema.py:48-52). Passing a non-Filter attribute (module:dict) would close that hole.
…FILTER-444) ## What does this PR do? Adds the SDK affordance for filters to declare their `frame.data` output as a build-time JSON Schema (draft 2020-12), parallel to `FilterConfigBase` from [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442). Ships: - `FilterOutputSchema(BaseModel)` in `openfilter.filter_runtime.output` with `__schema_id__` (`$id`) and `__frame_data_key__` (dotted path on `frame.data`, surfaced as the `x-openfilter-frame-data-key` extension). No managed/resolve filtering — output schemas describe data, not config. - `openfilter.filter_runtime.shapes` catalog: `BoundingBox`, `Polygon`, `Mask`, `Keypoint`, `Detection`, `DetectionSet`, `Track`, `TrackSet`, `Pose`, `PoseSet`, `KeypointSet`, `OCRSpan`, `OCRSpanSet`, `ClassificationResult`. Each with stable `$id` at `https://schemas.plainsight.ai/shapes/<kebab>/v1` and per-shape coordinate conventions documented inline (pixel xyxy for boxes / polygons / masks / OCR quads; normalized [0,1] for keypoints). - `openfilter emit-schema --kind {config,output}` flag, defaulting to `config` to preserve the [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) contract. Auto-detection respects the selected base class; `--include-managed` is rejected with `--kind output`. ## Why is this needed? Consumers of `frame.data` (a shared visualizer, webvis SSE clients, GT label visualization, downstream filters) currently negotiate shapes out-of-band — every consumer hard-codes its own dialect against every producer. Declaring output as a schema lets all consumers bind against one interop type. The catalog is **normative, not descriptive** — it defines what filters *should* emit, with per-filter migrations as separate follow-ups (catalog shapes were seeded from the superset of what `filter-sam3-detector`, `filter-protege-model`, `filter-optical-character-recognition`, and `filter-pose-estimation` currently emit on `frame.data`). Filters that don't want to use the catalog can ship their own bespoke `FilterOutputSchema` with a filter-owned `$id`; catalog `$ref`s are an opt-in convenience, never a gate. ## How was it tested? - 26 new unit tests in `tests/test_output_schema.py` covering base-class semantics (`$id` / frame-data-key emission, draft 2020-12 conformance, catalog `$ref` resolution into `$defs`, bespoke schema with no catalog dep), every catalog shape's `$id`, and shape-specific validation (BoundingBox pixel xyxy, Keypoint normalized range, Detection open-set label-only, Track inheritance, OCRSpan quad arity, Pose skeleton convention, ClassificationResult multilabel toggle). - 5 new CLI tests in `tests/test_emit_schema_cli.py` covering `--kind output` happy path, auto-detection of a single output class, cross-base rejection (config kind on output class and vice versa), and `--include-managed` incompatibility with `--kind output`. - Full suite: 559 passing (36 new + 523 unchanged); 42 pre-existing failures in `test_filter_video_out.py` / `test_filter_rest.py` / `test_filter_webvis.py` are gated on `av` / `fastapi` extras absent from `--extra dev` and unrelated to this change. ## Related Issues - Implements [FILTER-444](https://plainsight-ai.atlassian.net/browse/FILTER-444) — SDK half. - Workflow plumbing for emit + OCI sibling-artifact attach (config + output) is split into [FILTER-450](https://plainsight-ai.atlassian.net/browse/FILTER-450). - Sibling to [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) (config schema CLI, shipped in #87). ## Screenshots or Logs N/A — pure SDK module; no UI surface. ## Checklist - [ ] I have read and agreed to the terms of the [LICENSE](../LICENSE) - [ ] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide - [ ] I have followed the [coding style](../CONTRIBUTING.md#coding-style) - [x] I have signed all commits in compliance with the DCO (`git commit -s`) - [x] I have added or updated **tests** as needed - [ ] I have added or updated **documentation** as needed (module-level docstrings only; no separate docs-site change) Signed-off-by: stwilt <swilt@plainsight.ai>
…FILTER-444) ## What does this PR do? Adds the SDK affordance for filters to declare their `frame.data` output as a build-time JSON Schema (draft 2020-12), parallel to `FilterConfigBase` from [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442). Ships: - `FilterOutputSchema(BaseModel)` in `openfilter.filter_runtime.output` with `__schema_id__` (`$id`) and `__frame_data_key__` (dotted path on `frame.data`, surfaced as the `x-openfilter-frame-data-key` extension). No managed/resolve filtering — output schemas describe data, not config. - `openfilter.filter_runtime.shapes` catalog: `BoundingBox`, `Polygon`, `Mask`, `Keypoint`, `Detection`, `DetectionSet`, `Track`, `TrackSet`, `Pose`, `PoseSet`, `KeypointSet`, `OCRSpan`, `OCRSpanSet`, `ClassificationResult`. Each with stable `$id` at `https://schemas.plainsight.ai/shapes/<kebab>/v1` and per-shape coordinate conventions documented inline (pixel xyxy for boxes / polygons / masks / OCR quads; normalized [0,1] for keypoints). - `openfilter emit-schema --kind {config,output}` flag, defaulting to `config` to preserve the [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) contract. Auto-detection respects the selected base class; `--include-managed` is rejected with `--kind output`. ## Why is this needed? Consumers of `frame.data` (a shared visualizer, webvis SSE clients, GT label visualization, downstream filters) currently negotiate shapes out-of-band — every consumer hard-codes its own dialect against every producer. Declaring output as a schema lets all consumers bind against one interop type. The catalog is **normative, not descriptive** — it defines what filters *should* emit, with per-filter migrations as separate follow-ups (catalog shapes were seeded from the superset of what `filter-sam3-detector`, `filter-protege-model`, `filter-optical-character-recognition`, and `filter-pose-estimation` currently emit on `frame.data`). Filters that don't want to use the catalog can ship their own bespoke `FilterOutputSchema` with a filter-owned `$id`; catalog `$ref`s are an opt-in convenience, never a gate. ## How was it tested? - 26 new unit tests in `tests/test_output_schema.py` covering base-class semantics (`$id` / frame-data-key emission, draft 2020-12 conformance, catalog `$ref` resolution into `$defs`, bespoke schema with no catalog dep), every catalog shape's `$id`, and shape-specific validation (BoundingBox pixel xyxy, Keypoint normalized range, Detection open-set label-only, Track inheritance, OCRSpan quad arity, Pose skeleton convention, ClassificationResult multilabel toggle). - 5 new CLI tests in `tests/test_emit_schema_cli.py` covering `--kind output` happy path, auto-detection of a single output class, cross-base rejection (config kind on output class and vice versa), and `--include-managed` incompatibility with `--kind output`. - Full suite: 559 passing (36 new + 523 unchanged); 42 pre-existing failures in `test_filter_video_out.py` / `test_filter_rest.py` / `test_filter_webvis.py` are gated on `av` / `fastapi` extras absent from `--extra dev` and unrelated to this change. ## Related Issues - Implements [FILTER-444](https://plainsight-ai.atlassian.net/browse/FILTER-444) — SDK half. - Workflow plumbing for emit + OCI sibling-artifact attach (config + output) is split into [FILTER-450](https://plainsight-ai.atlassian.net/browse/FILTER-450). - Sibling to [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) (config schema CLI, shipped in #87). ## Screenshots or Logs N/A — pure SDK module; no UI surface. ## Checklist - [x] I have read and agreed to the terms of the [LICENSE](../LICENSE) - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide - [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style) - [x] I have signed all commits in compliance with the DCO (`git commit -s`) - [x] I have added or updated **tests** as needed - [x] I have added or updated **documentation** as needed Signed-off-by: stwilt <swilt@plainsight.ai>
…FILTER-444) ## What does this PR do? Adds the SDK affordance for filters to declare their `frame.data` output as a build-time JSON Schema (draft 2020-12), parallel to `FilterConfigBase` from [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442). Ships: - `FilterOutputSchema(BaseModel)` in `openfilter.filter_runtime.output` with `__schema_id__` (`$id`) and `__frame_data_key__` (dotted path on `frame.data`, surfaced as the `x-openfilter-frame-data-key` extension). No managed/resolve filtering — output schemas describe data, not config. - `openfilter.filter_runtime.shapes` catalog: `BoundingBox`, `Polygon`, `Mask`, `Keypoint`, `Detection`, `DetectionSet`, `Track`, `TrackSet`, `Pose`, `PoseSet`, `KeypointSet`, `OCRSpan`, `OCRSpanSet`, `ClassificationResult`. Each with stable `$id` at `https://schemas.plainsight.ai/shapes/<kebab>/v1` and per-shape coordinate conventions documented inline (pixel xyxy for boxes / polygons / masks / OCR quads; normalized [0,1] for keypoints). - `openfilter emit-schema --kind {config,output}` flag, defaulting to `config` to preserve the [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) contract. Auto-detection respects the selected base class; `--include-managed` is rejected with `--kind output`. ## Why is this needed? Consumers of `frame.data` (a shared visualizer, webvis SSE clients, GT label visualization, downstream filters) currently negotiate shapes out-of-band — every consumer hard-codes its own dialect against every producer. Declaring output as a schema lets all consumers bind against one interop type. The catalog is **normative, not descriptive** — it defines what filters *should* emit, with per-filter migrations as separate follow-ups (catalog shapes were seeded from the superset of what `filter-sam3-detector`, `filter-protege-model`, `filter-optical-character-recognition`, and `filter-pose-estimation` currently emit on `frame.data`). Filters that don't want to use the catalog can ship their own bespoke `FilterOutputSchema` with a filter-owned `$id`; catalog `$ref`s are an opt-in convenience, never a gate. ## How was it tested? - 26 new unit tests in `tests/test_output_schema.py` covering base-class semantics (`$id` / frame-data-key emission, draft 2020-12 conformance, catalog `$ref` resolution into `$defs`, bespoke schema with no catalog dep), every catalog shape's `$id`, and shape-specific validation (BoundingBox pixel xyxy, Keypoint normalized range, Detection open-set label-only, Track inheritance, OCRSpan quad arity, Pose skeleton convention, ClassificationResult multilabel toggle). - 5 new CLI tests in `tests/test_emit_schema_cli.py` covering `--kind output` happy path, auto-detection of a single output class, cross-base rejection (config kind on output class and vice versa), and `--include-managed` incompatibility with `--kind output`. - Full suite: 559 passing (36 new + 523 unchanged); 42 pre-existing failures in `test_filter_video_out.py` / `test_filter_rest.py` / `test_filter_webvis.py` are gated on `av` / `fastapi` extras absent from `--extra dev` and unrelated to this change. ## Related Issues - Implements [FILTER-444](https://plainsight-ai.atlassian.net/browse/FILTER-444) — SDK half. - Workflow plumbing for emit + OCI sibling-artifact attach (config + output) is split into [FILTER-450](https://plainsight-ai.atlassian.net/browse/FILTER-450). - Sibling to [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) (config schema CLI, shipped in #87). ## Screenshots or Logs N/A — pure SDK module; no UI surface. ## Checklist - [x] I have read and agreed to the terms of the [LICENSE](../LICENSE) - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide - [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style) - [x] I have signed all commits in compliance with the DCO (`git commit -s`) - [x] I have added or updated **tests** as needed - [x] I have added or updated **documentation** as needed Signed-off-by: stwilt <swilt@plainsight.ai>
…FILTER-444) (#88) …FILTER-444) ## What does this PR do? Adds the SDK affordance for filters to declare their `frame.data` output as a build-time JSON Schema (draft 2020-12), parallel to `FilterConfigBase` from [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442). Ships: - `FilterOutputSchema(BaseModel)` in `openfilter.filter_runtime.output` with `__schema_id__` (`$id`) and `__frame_data_key__` (dotted path on `frame.data`, surfaced as the `x-openfilter-frame-data-key` extension). No managed/resolve filtering — output schemas describe data, not config. - `openfilter.filter_runtime.shapes` catalog: `BoundingBox`, `Polygon`, `Mask`, `Keypoint`, `Detection`, `DetectionSet`, `Track`, `TrackSet`, `Pose`, `PoseSet`, `KeypointSet`, `OCRSpan`, `OCRSpanSet`, `ClassificationResult`. Each with stable `$id` at `https://schemas.plainsight.ai/shapes/<kebab>/v1` and per-shape coordinate conventions documented inline (pixel xyxy for boxes / polygons / masks / OCR quads; normalized [0,1] for keypoints). - `openfilter emit-schema --kind {config,output}` flag, defaulting to `config` to preserve the [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) contract. Auto-detection respects the selected base class; `--include-managed` is rejected with `--kind output`. ## Why is this needed? Consumers of `frame.data` (a shared visualizer, webvis SSE clients, GT label visualization, downstream filters) currently negotiate shapes out-of-band — every consumer hard-codes its own dialect against every producer. Declaring output as a schema lets all consumers bind against one interop type. The catalog is **normative, not descriptive** — it defines what filters *should* emit, with per-filter migrations as separate follow-ups (catalog shapes were seeded from the superset of what `filter-sam3-detector`, `filter-protege-model`, `filter-optical-character-recognition`, and `filter-pose-estimation` currently emit on `frame.data`). Filters that don't want to use the catalog can ship their own bespoke `FilterOutputSchema` with a filter-owned `$id`; catalog `$ref`s are an opt-in convenience, never a gate. ## How was it tested? - 26 new unit tests in `tests/test_output_schema.py` covering base-class semantics (`$id` / frame-data-key emission, draft 2020-12 conformance, catalog `$ref` resolution into `$defs`, bespoke schema with no catalog dep), every catalog shape's `$id`, and shape-specific validation (BoundingBox pixel xyxy, Keypoint normalized range, Detection open-set label-only, Track inheritance, OCRSpan quad arity, Pose skeleton convention, ClassificationResult multilabel toggle). - 5 new CLI tests in `tests/test_emit_schema_cli.py` covering `--kind output` happy path, auto-detection of a single output class, cross-base rejection (config kind on output class and vice versa), and `--include-managed` incompatibility with `--kind output`. - Full suite: 559 passing (36 new + 523 unchanged); 42 pre-existing failures in `test_filter_video_out.py` / `test_filter_rest.py` / `test_filter_webvis.py` are gated on `av` / `fastapi` extras absent from `--extra dev` and unrelated to this change. ## Related Issues - Implements [FILTER-444](https://plainsight-ai.atlassian.net/browse/FILTER-444) — SDK half. - Workflow plumbing for emit + OCI sibling-artifact attach (config + output) is split into [FILTER-450](https://plainsight-ai.atlassian.net/browse/FILTER-450). - Sibling to [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) (config schema CLI, shipped in #87). ## Screenshots or Logs N/A — pure SDK module; no UI surface. ## Checklist - [x] I have read and agreed to the terms of the [LICENSE](../LICENSE) - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide - [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style) - [x] I have signed all commits in compliance with the DCO (`git commit -s`) - [x] I have added or updated **tests** as needed - [x] I have added or updated **documentation** as needed [FILTER-442]: https://plainsight-ai.atlassian.net/browse/FILTER-442?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [FILTER-442]: https://plainsight-ai.atlassian.net/browse/FILTER-442?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Signed-off-by: stwilt <swilt@plainsight.ai>
📦 Pull Request
📋 What does this PR do?
Adds the
openfilter emit-schema <module>[:Class]CLI subcommand — the build-time producer half of the FC-3 schema-transport contract. The command imports a filter module, locates itsFilterConfigBasesubclass, and writes the emitted JSON Schema (draft 2020-12) to stdout (default) or-o <path>. Filter Dockerfiles invoke this at build time; the resulting JSON is pushed as a sibling OCI artifact (application/vnd.openfilter.schema+json) and the image manifest is stamped with acom.plainsight.openfilter.schemalabel carrying the artifact's sha256 digest.Specifically:
openfilter/cli/cmd_emit_schema.py— argparse-driven subcommand withmodule/module:Classresolution, automatic candidate detection (errors out cleanly when a module declares 0 or 2+FilterConfigBasesubclasses),--include-managedopt-in to surface platform-managed fields, and-o <path>output redirection.openfilterCLI dispatcher (openfilter/cli/__main__.py) to translatecmd-with-hyphenstocmd_with_hyphensso subcommands can use their canonical hyphenated spellings without breaking the underscore-named Python handler convention.tests/test_emit_schema_cli.pycovering stdout output,-o <path>,--include-managed, single-class auto-pick, and unknown-class failure.The wiring of the sibling-artifact upload + label-stamping into
cloudbuild-cascade.yamlis tracked as a sub-step of FILTER-442; it depends on PLAT-side FC-5 ingest and lands separately.🔍 Why is this needed?
FILTER-441 landed
FilterConfigBase.emit_schema()— the in-process serializer that produces each filter's JSON Schema. This PR is the CLI wrapper Dockerfiles invoke at build time so the artifact can ride alongside the image without a Python-runtime hop in the platform's ingest path.Once both are in place, the transport contract closes: filter CI emits the schema, pushes it as a sibling OCI artifact, and stamps the image manifest's
com.plainsight.openfilter.schemalabel with the sha256 digest. The platform fetches the artifact by digest from the same registry without pulling the filter image's layers. Integrity is transitively protected by whatever trust we already place in the registry serving the image bytes — no separate key infrastructure needed.(Cosign-signed attestations were considered for the transport but deferred to a future-driver-only ticket — see
FILTER-448, closed Won't Do — the marginal threat surface didn't justify standing up KMS for the first internal use.)
🧪 How was it tested?
5 unit tests in
tests/test_emit_schema_cli.py:test_emit_schema_cli_stdout—module:Classspec, stdout output,$schemakeyword + managed-field strippingtest_emit_schema_cli_writes_file—-o <path>redirectiontest_emit_schema_cli_include_managed_surfaces_overrides—--include-managedround-trips FC-2 markerstest_emit_schema_cli_auto_picks_single_class— module-only spec finds the loneFilterConfigBasesubclasstest_emit_schema_cli_fails_on_unknown_class— typo'd class name errors out cleanlyCombined with FILTER-441's config-side suite (already merged in #86, 46 tests), the full FC-1 / FC-1.5 / FC-2 / FC-3-emission contract has 51 tests exercising it end-to-end.
🔗 Related Issues
FilterConfigBase.emit_schema()(merged in feat(filter_runtime): typed FilterConfigBase + emit_schema (FILTER-441) #86)🖼️ Screenshots or Logs (if applicable)
✅ Checklist
git commit -s)docs/declarative-config.md's "Schema emission" section, landed with FILTER-441 in feat(filter_runtime): typed FilterConfigBase + emit_schema (FILTER-441) #86, already describes this CLI's surface)