Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# talonctl pre-commit hooks. Mirrors the CI lint gate so formatting drift
# cannot slip through to the ci.yml check. Install once with:
# pre-commit install
# After that, every `git commit` runs the hooks automatically.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.10 # keep in sync with the ruff pin used by .github/workflows/ci.yml
hooks:
- id: ruff-format
args: [--exclude, src/talonctl/_version.py]
- id: ruff
args: [--exclude, src/talonctl/_version.py]
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Changelog

## v0.2.1 — 2026-04-16

### Fixed

- Dashboard apply no longer leaks `_template_path` into the Humio schema
validation payload (closes #7). `DashboardProvider._prepare_yaml_payload`
and `_normalize_for_hash` now route through a new
`core/template_sanitizer` helper that strips `_`-prefixed tool-internal
keys and the universally-IaC field set `{resource_id, type, dependencies,
metadata}`. Dashboard-specific transforms (`tags → labels`, `description`
strip) are preserved unchanged.

### Chore

- Added `.pre-commit-config.yaml` with ruff hooks mirroring the CI lint
gate. Install with `pre-commit install` after `pip install -e .[dev]`.

### Skipped

- **v0.2.0** was never released to PyPI. Its git tag was pushed malformed
(`v.0.2.0` with an extra dot) and the release job's VCS-versioning could
not parse it. The malformed tag remains on the remote — a GitHub
repository rule blocks tag deletion — but it is orphaned: hatchling's
VCS versioning cannot parse it, so no release will ever be cut from
it. v0.2.1 is the first working 0.2.x release.
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ pip install -e .[dev]
pytest tests/ -v
```

### Installing pre-commit hooks

Once, after `pip install -e .[dev]`:

```bash
pre-commit install
```

After that, `git commit` auto-runs `ruff format` and `ruff check`. The config in `.pre-commit-config.yaml` mirrors the CI lint gate — keeping both green locally means CI stays green.

### Adding a New CLI Command

1. Create `src/talonctl/commands/mycommand.py` with a Click command or group
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ dependencies = [
]

[project.optional-dependencies]
dev = ["pytest>=7.0.0", "ruff>=0.8.0"]
dev = ["pre-commit>=3.7", "pytest>=7.0.0", "ruff>=0.8.0"]

[project.scripts]
talonctl = "talonctl.cli:cli"
Expand Down
50 changes: 50 additions & 0 deletions src/talonctl/core/template_sanitizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Shared template-sanitization helpers.

Every provider's API-payload prep and content-hash path calls into this module
as its FIRST step. Provider-specific stripping/transforms (e.g. dashboard's
tags->labels rename, or a provider deciding `description` is IaC-only for its
resource type) run AFTER this helper.

Adding a new universally-IaC top-level field → update RESERVED_TOP_LEVEL_FIELDS
here, once. Adding a new internal field → use the `_` prefix convention, no
code change. Adding a provider-specific IaC-only field (like dashboard's
stripping of `description`) → keep that logic in the provider, not here.
"""

from __future__ import annotations

from typing import Any, Dict

# Single source of truth for "what is universally IaC-only across ALL providers."
# A field belongs here only if there is no provider where it is an API field.
# Fields like `description` and `tags` are intentionally NOT in this set because
# they are API fields on detection/saved_search but not on dashboard — providers
# handle them per-type.
RESERVED_TOP_LEVEL_FIELDS = frozenset(
{
"resource_id",
"type",
"dependencies",
"metadata",
}
)


def strip_for_api(template: Dict[str, Any]) -> Dict[str, Any]:
"""Return a shallow-copied dict with universally-IaC, internal, and metadata
fields removed.

This is the FIRST step of every provider's payload prep. Providers then apply
their own provider-specific stripping and transforms on the returned dict.
Callers that need to mutate sub-dicts (e.g. widget UUID normalization in
dashboards) should apply their own copy.deepcopy AFTER calling this helper.
"""
return {k: v for k, v in template.items() if not k.startswith("_") and k not in RESERVED_TOP_LEVEL_FIELDS}


def strip_for_hash(template: Dict[str, Any]) -> Dict[str, Any]:
"""Identical rules to strip_for_api — but named separately so callers can
reason about intent (deploy payload vs. content-hash input) without coupling.
If the two rules ever diverge, they diverge here, not across seven providers.
"""
return strip_for_api(template)
50 changes: 27 additions & 23 deletions src/talonctl/providers/dashboard_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,10 @@
import yaml

from talonctl.core.base_provider import BaseResourceProvider, ResourceChange, ResourceAction
from talonctl.core.template_sanitizer import strip_for_api, strip_for_hash

logger = logging.getLogger(__name__)

# IaC-only fields stripped before API calls and content hashing
IAC_ONLY_FIELDS = {"resource_id", "type", "description", "tags", "_search_domain", "dependencies"}

# Widget types that do NOT require a queryString
NON_QUERY_WIDGET_TYPES = {"note", "parameterPanel"}

Expand Down Expand Up @@ -85,34 +83,37 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]:
def _normalize_for_hash(template: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize dashboard YAML for deterministic hashing.

1. Strip IaC-only fields
2. Re-key widgets by (section_order, position) to remove UUID sensitivity
3. Update section widgetIds to match
1. Strip universally-IaC and `_`-prefixed fields via template_sanitizer.
2. Strip dashboard-specific IaC fields (`description` is not in the Humio
dashboard schema at top level).
3. Re-key widgets by (section_order, position) to remove UUID sensitivity.
4. Update section widgetIds to match.
"""
data = copy.deepcopy(template)
data = copy.deepcopy(strip_for_hash(template))

# Strip IaC-only fields
for field in IAC_ONLY_FIELDS:
data.pop(field, None)
# Dashboard-specific: preserve pre-helper behavior. The previous
# IAC_ONLY_FIELDS set excluded both `description` and `tags` from the hash
# input; keep doing that here to avoid a silent hash-behavior change when
# routing through the helper. (Whether tags *should* affect the hash is
# a separate design question — out of scope for this hotfix.)
data.pop("description", None)
data.pop("tags", None)

sections = data.get("sections", {})
widgets = data.get("widgets", {})

# Build ordered widget list: sort sections by order, then iterate widgetIds
ordered_widget_ids = []
ordered_widget_ids: list[str] = []
for _section_id, section in sorted(sections.items(), key=lambda s: s[1].get("order", 0)):
for wid in section.get("widgetIds", []):
if wid not in ordered_widget_ids:
ordered_widget_ids.append(wid)

# Include widgets not referenced by any section (append at end)
for wid in widgets:
if wid not in ordered_widget_ids:
ordered_widget_ids.append(wid)

# Re-key widgets as widget-0, widget-1, ...
new_widgets = {}
id_map = {}
new_widgets: Dict[str, Any] = {}
id_map: Dict[str, str] = {}
for i, old_id in enumerate(ordered_widget_ids):
new_id = f"widget-{i}"
id_map[old_id] = new_id
Expand All @@ -121,7 +122,6 @@ def _normalize_for_hash(template: Dict[str, Any]) -> Dict[str, Any]:

data["widgets"] = new_widgets

# Update section widgetIds
for section in sections.values():
section["widgetIds"] = [id_map.get(wid, wid) for wid in section.get("widgetIds", [])]

Expand All @@ -138,18 +138,22 @@ def compute_content_hash(self, template: Dict[str, Any]) -> str:
def _prepare_yaml_payload(template: Dict[str, Any]) -> str:
"""Prepare dashboard YAML for API upload.

Strips IaC-only fields, converts tags->labels, preserves everything else.
Strips universally-IaC + `_`-prefixed fields via template_sanitizer, then
applies dashboard-specific transforms: tags→labels rename, description
strip (not in Humio dashboard schema).
"""
data = copy.deepcopy(template)
data = strip_for_api(template)
# strip_for_api returns a shallow copy; we're about to mutate sub-keys,
# so deepcopy to avoid touching the caller's dict.
data = copy.deepcopy(data)

# Convert tags -> labels
# Dashboard-specific: tags -> labels rename.
tags = data.pop("tags", [])
if tags:
data["labels"] = tags

# Strip IaC-only fields (except tags, already handled)
for field in IAC_ONLY_FIELDS - {"tags"}:
data.pop(field, None)
# Dashboard-specific: description is IaC-only for dashboards.
data.pop("description", None)

return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)

Expand Down
27 changes: 7 additions & 20 deletions src/talonctl/providers/detection_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ def _validate_ads_ref_dict(self, ref: Dict[str, Any], field_path: str) -> List[s
unknown = set(ref.keys()) - self.ADS_REF_ALLOWED_KEYS
if unknown:
errs.append(
f"Unknown ref dict key(s) in {field_path}: {', '.join(sorted(unknown))}. "
f"Known keys: path, label"
f"Unknown ref dict key(s) in {field_path}: {', '.join(sorted(unknown))}. Known keys: path, label"
)
if "path" not in ref:
errs.append(f"{field_path} ref dict missing required 'path' key")
Expand Down Expand Up @@ -236,8 +235,7 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]:
)
else:
errors.append(
f"{path_tag} must be a string, inline FP dict, or "
f"ref dict ({{path, label?}})"
f"{path_tag} must be a string, inline FP dict, or ref dict ({{path, label?}})"
)

# Per-entry validation for validation (list of string | ref-dict; no inline-dict form).
Expand All @@ -253,9 +251,7 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]:
# defined for this field, so those errors are sufficient.
errors.extend(self._validate_ads_ref_dict(entry, path_tag))
else:
errors.append(
f"{path_tag} must be a string or ref dict ({{path, label?}})"
)
errors.append(f"{path_tag} must be a string or ref dict ({{path, label?}})")

# Type-union validation for response (string | ref-dict).
if "response" in ads:
Expand All @@ -265,9 +261,7 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]:
elif isinstance(response, dict):
errors.extend(self._validate_ads_ref_dict(response, "ads.response"))
else:
errors.append(
"ads.response must be a string or ref dict ({path, label?})"
)
errors.append("ads.response must be a string or ref dict ({path, label?})")

# Validate metadata: block if present (optional block, strict when present).
# See docs/superpowers/specs/2026-04-16-metadata-schema-and-ads-refs-design.md §1.
Expand All @@ -280,10 +274,7 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]:
unknown = set(metadata.keys()) - self.METADATA_ALLOWED_FIELDS
if unknown:
known = ", ".join(sorted(self.METADATA_ALLOWED_FIELDS))
errors.append(
f"Unknown metadata key(s): {', '.join(sorted(unknown))}. "
f"Known keys: {known}"
)
errors.append(f"Unknown metadata key(s): {', '.join(sorted(unknown))}. Known keys: {known}")

# Validate date fields (YYYY-MM-DD; last_tuned additionally allows null).
for field in self.METADATA_DATE_FIELDS:
Expand All @@ -302,18 +293,14 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]:
if "tune_count" in metadata:
val = metadata["tune_count"]
if isinstance(val, bool) or not isinstance(val, int) or val < 0:
errors.append(
f"metadata.tune_count must be a non-negative integer (got {val!r})"
)
errors.append(f"metadata.tune_count must be a non-negative integer (got {val!r})")

# Validate confidence enum.
if "confidence" in metadata:
val = metadata["confidence"]
if val not in self.METADATA_CONFIDENCE_VALUES:
allowed = ", ".join(sorted(self.METADATA_CONFIDENCE_VALUES))
errors.append(
f"metadata.confidence must be one of: {allowed} (got {val!r})"
)
errors.append(f"metadata.confidence must be one of: {allowed} (got {val!r})")

return errors

Expand Down
61 changes: 61 additions & 0 deletions tests/test_dashboard_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,3 +546,64 @@ def test_provider_in_init_exports(self):
from talonctl.providers import DashboardProvider

assert DashboardProvider is not None


# --- Issue #7 regression: _template_path must not leak into Humio payload ---


class TestIssue7Regression:
"""Direct regression for github.com/willwebster5/talonctl#7."""

def test_template_path_does_not_leak_into_yaml_payload(self, provider, valid_template):
tmpl = copy.deepcopy(valid_template)
tmpl["_template_path"] = "/home/user/project/resources/dashboards/my.yaml"

yaml_str = provider._prepare_yaml_payload(tmpl)

assert "_template_path" not in yaml_str, (
"issue #7: _template_path leaked into dashboard YAML payload — "
"Humio schema validation will reject the upload."
)

def test_resource_id_and_type_and_dependencies_stripped_from_payload(self, provider, valid_template):
tmpl = copy.deepcopy(valid_template)
tmpl["dependencies"] = []
yaml_str = provider._prepare_yaml_payload(tmpl)

# Universally-IaC fields must not reach Humio.
assert "resource_id:" not in yaml_str
assert "dependencies:" not in yaml_str
# `type:` is a valid YAML key inside widgets (e.g. type: query), so only
# assert that top-level `type: dashboard` is absent. Parse to verify.
data = yaml.safe_load(yaml_str)
assert "type" not in data
assert "resource_id" not in data
assert "dependencies" not in data

def test_description_still_stripped_for_dashboard_specifically(self, provider, valid_template):
# Dashboard-specific behavior preserved: Humio dashboard YAML schema
# does not carry `description` at the top level.
data = yaml.safe_load(provider._prepare_yaml_payload(valid_template))
assert "description" not in data

def test_tags_renamed_to_labels(self, provider, valid_template):
data = yaml.safe_load(provider._prepare_yaml_payload(valid_template))
assert "tags" not in data
assert data.get("labels") == ["test"]

def test_future_internal_field_stripped(self, provider, valid_template):
# Bug-class coverage: any future _-prefixed tool-internal field is
# stripped without needing a code change.
tmpl = copy.deepcopy(valid_template)
tmpl["_some_future_internal"] = "should-not-leak"
yaml_str = provider._prepare_yaml_payload(tmpl)
assert "_some_future_internal" not in yaml_str

def test_normalize_for_hash_ignores_template_path(self, provider, valid_template):
hash_without = provider.compute_content_hash(valid_template)

tmpl_with_path = copy.deepcopy(valid_template)
tmpl_with_path["_template_path"] = "/tmp/different/path.yaml"
hash_with = provider.compute_content_hash(tmpl_with_path)

assert hash_without == hash_with
Loading
Loading