[store_yaml] Record uncaptured includes as an envelope note in both modes

This commit is contained in:
J. Nick Koston
2026-07-03 21:20:53 -05:00
parent 6fdbdcbffe
commit 4dbf15ac95
2 changed files with 30 additions and 24 deletions
+20 -15
View File
@@ -197,6 +197,22 @@ def _collect_sensitive_values() -> dict[str, _SensitiveValue]:
return result
# Envelope path of the note recording includes that could not be captured.
UNCAPTURED_NOTE_PATH = "store_yaml_uncaptured.yaml"
def _uncaptured_note(unresolved: list[str]) -> tuple[str, bytes]:
"""Comment-only YAML entry listing includes that could not be captured, so
a recovered config never silently appears complete. Emitted for both the
redacted and verbatim paths; user files are never modified to carry it."""
text = (
"# store_yaml: the following !include paths use substitutions and\n"
"# could not be captured; restore these files manually:\n"
+ "".join(f"# {inc}\n" for inc in unresolved)
)
return (UNCAPTURED_NOTE_PATH, text.encode("utf-8"))
def _build_secrets_skeleton(keys: set[str]) -> bytes:
parts = [SECRETS_SKELETON_HEADER]
parts.extend(f'{key}: ""\n' for key in sorted(keys))
@@ -204,7 +220,7 @@ def _build_secrets_skeleton(keys: set[str]) -> bytes:
def _generate_redacted_files(
entries: list[tuple[str, Path]], secret_rels: set[str], unresolved: list[str]
entries: list[tuple[str, Path]], secret_rels: set[str]
) -> list[tuple[str, bytes]]:
"""Re-generate each captured file from its parse tree with cv.sensitive
values emitted as `!secret <name>` references, and replace secrets files
@@ -248,19 +264,6 @@ def _generate_redacted_files(
"embed secrets deliberately."
)
if unresolved:
# Record the gap inside the recovered config itself, not just in a
# compile-time log line: substitution-pathed includes can't be
# captured, so the user must restore those files manually.
# Discovery parses the entry file first, so entries[0] is the entry.
entry_rel = entries[0][0]
texts[entry_rel] = (
"# store_yaml: the following !include paths use substitutions and\n"
"# could not be captured; restore these files manually:\n"
+ "".join(f"# {inc}\n" for inc in unresolved)
+ texts[entry_rel]
)
skeleton = _build_secrets_skeleton(skeleton_keys)
result = [
(rel, skeleton if rel in secret_rels else texts[rel].encode("utf-8"))
@@ -337,7 +340,9 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_INCLUDE_SECRETS]:
files = _read_files_verbatim(entries)
else:
files = _generate_redacted_files(entries, secret_rels, discovered.unresolved)
files = _generate_redacted_files(entries, secret_rels)
if discovered.unresolved:
files.append(_uncaptured_note(discovered.unresolved))
envelope = _pack_envelope(files)
compressed = zstd.compress(envelope, level=ZSTD_LEVEL)
+10 -9
View File
@@ -10,10 +10,12 @@ import pytest
from esphome import yaml_util
from esphome.components.store_yaml import (
SECRETS_SKELETON_HEADER,
UNCAPTURED_NOTE_PATH,
_gather_files,
_generate_redacted_files,
_pack_envelope,
_read_files_verbatim,
_uncaptured_note,
unpack_envelope,
)
from esphome.core import CORE, EsphomeError
@@ -49,7 +51,7 @@ def _sources(
def _gather_redacted(discovered: DiscoveredYamlFiles) -> dict[str, bytes]:
entries, secret_rels = _gather_files(discovered)
return dict(_generate_redacted_files(entries, secret_rels, discovered.unresolved))
return dict(_generate_redacted_files(entries, secret_rels))
# ---------------------------------------------------------------------------
@@ -272,14 +274,13 @@ def test_redacted_accepts_secret_only_values(project: Path) -> None:
assert 'api_key: ""' in files["secrets.yaml"].decode()
def test_redacted_records_unresolved_includes_in_entry_file(project: Path) -> None:
"""Substitution-pathed includes that can't be captured are noted inside the
recovered entry file, not just in a compile-time log line."""
discovered = _sources(project, "entry.yaml", "secrets.yaml")
discovered.unresolved.append("${board}.yaml")
files = _gather_redacted(discovered)
text = files["entry.yaml"].decode()
assert text.startswith("# store_yaml: the following !include paths")
def test_uncaptured_note_lists_missing_includes() -> None:
"""Substitution-pathed includes that can't be captured are recorded in a
dedicated envelope entry (both modes), not just a compile-time log line."""
rel, content = _uncaptured_note(["${board}.yaml"])
assert rel == UNCAPTURED_NOTE_PATH
text = content.decode()
assert text.startswith("# store_yaml:")
assert "# ${board}.yaml" in text