Skip to content

Conversation

@springfall2008
Copy link
Owner

Summary

Implements the Home Assistant secrets mechanism in Predbat, allowing users to store sensitive configuration values like API keys and passwords in a separate secrets.yaml file and reference them using the !secret tag in apps.yaml.

Changes

Core Implementation

  • Added load_secrets() method in hass.py that loads secrets.yaml from:
    1. Path specified in PREDBAT_SECRETS_FILE environment variable
    2. secrets.yaml in the same directory as apps.yaml
    3. /config/secrets.yaml (standard Home Assistant location)
  • Added custom YAML constructor secret_constructor() to handle !secret tags
  • Secrets are loaded before parsing apps.yaml for seamless integration
  • Missing secrets return None with a warning rather than raising an error

Documentation

  • Added comprehensive "Storing secrets" section to docs/apps-yaml.md
  • Includes usage examples, benefits, and debugging tips
  • Added notes throughout documentation recommending secrets for:
    • ha_key - Home Assistant long-lived access token
    • ge_cloud_key - GivEnergy Cloud API key
    • solcast_api_key - Solcast solar forecast API key
    • octopus_api_key - Octopus Energy API key
    • axle_api_key - Axle Energy VPP API key
    • ohme_password - Ohme charger password

Template & Testing

  • Created templates/secrets.yaml with common API key examples
  • Added comprehensive test suite in tests/test_secrets.py covering:
    • No secrets file handling
    • Loading from different locations
    • !secret tag resolution
    • Missing secret key handling
  • All tests pass ✅

Benefits

  • Security: Keeps sensitive data separate from configuration files
  • Safety: Makes apps.yaml safer to share for troubleshooting
  • Organization: All secrets centralized in one location
  • Compatibility: Fully compatible with Home Assistant's secrets system

Usage Example

secrets.yaml:

octopus_api_key: "sk_live_abc123xyz..."
solcast_api_key: "your_api_key_here"

apps.yaml:

pred_bat:
  octopus_api_key: !secret octopus_api_key
  solcast_api_key: !secret solcast_api_key

Testing

  • Unit tests added and passing
  • Pre-commit checks passing
  • Backward compatible - works without secrets.yaml

Copilot AI review requested due to automatic review settings December 27, 2025 19:30
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements Home Assistant's secrets mechanism in Predbat, allowing users to store sensitive configuration values (API keys, passwords, tokens) in a separate secrets.yaml file and reference them using the !secret tag in apps.yaml. The implementation is backward compatible and works seamlessly without a secrets file.

Key changes:

  • Added load_secrets() and secret_constructor() methods to enable YAML !secret tag processing
  • Created comprehensive test suite covering various scenarios including missing files and missing keys
  • Updated documentation with usage examples and recommendations for securing sensitive config values

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
apps/predbat/hass.py Core implementation: secrets loading from multiple locations and YAML constructor for !secret tag resolution
apps/predbat/tests/test_secrets.py New test suite with 5 test cases covering secrets loading, tag resolution, and error handling
docs/apps-yaml.md Added comprehensive "Storing secrets" section with usage guide and updated config recommendations
apps/predbat/config/secrets.yaml Template file with examples of common secrets (API keys, passwords) and usage instructions
apps/predbat/config/apps.yaml Updated template to demonstrate !secret tag usage for sensitive config values
coverage/standalone_ha Added secrets.yaml copy for standalone testing
apps/predbat/unit_test.py Registered new secrets test suite
apps/predbat/predbat.py Version bump to v8.31.0
Comments suppressed due to low confidence (1)

apps/predbat/tests/test_secrets.py:119

  • The test cleanup is missing in exception paths. If an assertion fails before the cleanup code runs (del os.environ, os.remove), the environment variable and test files will remain, potentially affecting subsequent tests.

Consider wrapping each test section in try/finally blocks or using context managers to ensure cleanup happens even when assertions fail. For example:

os.environ["PREDBAT_APPS_FILE"] = "test_apps.yaml"
try:
    h = Hass()
    assert h.args.get("api_key") == "secret_value_789", ...
finally:
    if "PREDBAT_APPS_FILE" in os.environ:
        del os.environ["PREDBAT_APPS_FILE"]
    if os.path.exists("test_apps.yaml"):
        os.remove("test_apps.yaml")
    if os.path.exists("secrets.yaml"):
        os.remove("secrets.yaml")

This pattern should be applied to Test 4 (lines 89-97) and Test 5 (lines 113-119).

        yaml.dump(secrets_data, f)

    with open("test_apps.yaml", "w") as f:
        f.write("pred_bat:\n")
        f.write("  module: predbat\n")
        f.write("  class: PredBat\n")
        f.write("  missing_key: !secret non_existent_key\n")

    os.environ["PREDBAT_APPS_FILE"] = "test_apps.yaml"
    h = Hass()
    assert h.args.get("missing_key") is None, f"Expected None for missing secret, got {h.args.get('missing_key')}"
    print("    PASS - Missing secret key returns None and warns correctly")
    del os.environ["PREDBAT_APPS_FILE"]
    os.remove("test_apps.yaml")
    os.remove("secrets.yaml")

    print("**** test_secrets_loading PASSED ****")
    return False  # False = success in Predbat test framework


def run_secrets_tests(my_predbat=None):
    """
    Run all secrets tests
    """
    return test_secrets_loading()

Comment on lines +41 to +47
assert h.secrets == secrets_data, f"Expected {secrets_data}, got {h.secrets}"
os.remove("secrets.yaml")
print(" PASS - Secrets loaded from current directory")

# Test 3: Secrets file from PREDBAT_SECRETS_FILE env var
print(" Test 3: Secrets file from PREDBAT_SECRETS_FILE")
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test 2 doesn't clean up the secrets.yaml file if the assertion fails. If the assertion on line 45 fails, line 46 (os.remove) won't execute, leaving secrets.yaml in place for subsequent tests.

Consider using a try/finally block:

with open("secrets.yaml", "w") as f:
    yaml.dump(secrets_data, f)

try:
    h = Hass()
    assert h.secrets == secrets_data, f"Expected {secrets_data}, got {h.secrets}"
    print("    PASS - Secrets loaded from current directory")
finally:
    if os.path.exists("secrets.yaml"):
        os.remove("secrets.yaml")

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +60
os.remove(temp_secrets_file)
print(" PASS - Secrets loaded from PREDBAT_SECRETS_FILE")

# Test 4: Test !secret tag in apps.yaml
print(" Test 4: Test !secret tag resolution")
secrets_data = {"test_api_key": "secret_value_789", "test_username": "secret_user"}
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test 3 doesn't clean up the environment variable and temp file if the assertion fails. If the assertion on line 57 fails, lines 58-59 won't execute, potentially affecting subsequent tests or leaving temporary files on the system.

Consider using a try/finally block:

os.environ["PREDBAT_SECRETS_FILE"] = temp_secrets_file
try:
    h = Hass()
    assert h.secrets == secrets_data, f"Expected {secrets_data}, got {h.secrets}"
    print("    PASS - Secrets loaded from PREDBAT_SECRETS_FILE")
finally:
    if "PREDBAT_SECRETS_FILE" in os.environ:
        del os.environ["PREDBAT_SECRETS_FILE"]
    if os.path.exists(temp_secrets_file):
        os.remove(temp_secrets_file)

Copilot uses AI. Check for mistakes.
# pylint: disable=attribute-defined-outside-init

import os
import yaml
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'io' module is imported but never used in this file. All file operations use the built-in 'open()' function directly. This unused import should be removed.

Copilot uses AI. Check for mistakes.
Create a `secrets.yaml` file in one of these locations (checked in order, only the first one is read):

1. Path specified in `PREDBAT_SECRETS_FILE` environment variable
2. `secrets.yaml` in the same directory as your `apps.yaml`
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The priority order documented here does not match the actual implementation in hass.py. The documentation states the order is:

  1. PREDBAT_SECRETS_FILE environment variable
  2. secrets.yaml in same directory as apps.yaml
  3. /config/secrets.yaml (standard Home Assistant location)

But the actual code implementation checks:

  1. PREDBAT_SECRETS_FILE environment variable ✓
  2. secrets.yaml (current directory) ✓
  3. /config/secrets.yaml ✓

The issue is subtle: the documentation says "same directory as your apps.yaml" but the code actually checks "secrets.yaml" in the current working directory (wherever the process was started from), not necessarily where apps.yaml is located. If apps.yaml is loaded from a custom path via PREDBAT_APPS_FILE, the secrets.yaml lookup won't be relative to that location.

The documentation should be updated to accurately reflect this behavior.

Suggested change
2. `secrets.yaml` in the same directory as your `apps.yaml`
2. `secrets.yaml` in the current working directory (where the Predbat process is started; this may be different from the directory containing `apps.yaml` when using `PREDBAT_APPS_FILE`)

Copilot uses AI. Check for mistakes.
if os.path.exists("secrets.yaml"):
os.remove("secrets.yaml")
if os.path.exists("/config/secrets.yaml"):
os.remove("/config/secrets.yaml")
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test 1 attempts to remove /config/secrets.yaml but doesn't verify the test has write permissions to that directory. If the directory doesn't exist or is not writable, the os.remove call could raise an exception. The test should either:

  1. Check if the directory exists and is writable before attempting removal
  2. Wrap the removal in a try/except block
  3. Or use a more controlled test environment that doesn't rely on system paths

This is particularly important since /config/ is a system-level path that may have restricted permissions depending on where tests are run.

Suggested change
os.remove("/config/secrets.yaml")
try:
os.remove("/config/secrets.yaml")
except OSError:
# Ignore errors if /config is not writable or file cannot be removed
pass

Copilot uses AI. Check for mistakes.
Comment on lines +215 to +216
# Register custom YAML constructor for !secret tag
yaml.add_constructor("!secret", self.secret_constructor, Loader=yaml.SafeLoader)
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The yaml.add_constructor is being called globally for yaml.SafeLoader in the init method. This modifies a global YAML loader state and could cause issues if multiple Hass instances are created (as happens in the test suite). Each new instance will re-register the constructor, potentially causing side effects.

While this may work in practice since the constructor does the same thing, it's better practice to either:

  1. Only add the constructor once using a class-level flag to check if it's already registered
  2. Use a custom loader subclass instead of modifying the global SafeLoader

This is particularly important given that test_secrets.py creates 5 separate Hass() instances, each re-registering the global constructor.

Suggested change
# Register custom YAML constructor for !secret tag
yaml.add_constructor("!secret", self.secret_constructor, Loader=yaml.SafeLoader)
# Register custom YAML constructor for !secret tag only once per class
if not getattr(self.__class__, "_secret_constructor_registered", False):
yaml.add_constructor("!secret", self.secret_constructor, Loader=yaml.SafeLoader)
self.__class__._secret_constructor_registered = True

Copilot uses AI. Check for mistakes.
f.write("pred_bat:\n")
f.write(" module: predbat\n")
f.write(" class: PredBat\n")
f.write(" api_key: !secret test_api_key\n")
Copy link

Copilot AI Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable test_config is not used.

Copilot uses AI. Check for mistakes.
@springfall2008 springfall2008 merged commit 5cad6b2 into main Dec 27, 2025
1 check passed
@springfall2008 springfall2008 deleted the fixes7 branch December 27, 2025 19:38
@gcoan
Copy link
Collaborator

gcoan commented Dec 28, 2025

Fixes #1864, raised over a year ago!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants