ha-deploy-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ha_deploy_kit/__init__.py +9 -0
- ha_deploy_kit/cli.py +144 -0
- ha_deploy_kit/comparison.py +202 -0
- ha_deploy_kit/config.py +63 -0
- ha_deploy_kit/frontend.py +87 -0
- ha_deploy_kit/ha/__init__.py +20 -0
- ha_deploy_kit/ha/repairs_flow.py +48 -0
- ha_deploy_kit/ha/repairs_issue.py +46 -0
- ha_deploy_kit/ha/service.py +52 -0
- ha_deploy_kit/ha/serving.py +63 -0
- ha_deploy_kit/inventory.py +50 -0
- ha_deploy_kit/metadata/services.yaml.tmpl +6 -0
- ha_deploy_kit/metadata/strings.snippet.fixflow.json +15 -0
- ha_deploy_kit/metadata/strings.snippet.json +14 -0
- ha_deploy_kit/notify.py +150 -0
- ha_deploy_kit/settings.py +56 -0
- ha_deploy_kit/transport.py +117 -0
- ha_deploy_kit-0.1.0.dist-info/METADATA +199 -0
- ha_deploy_kit-0.1.0.dist-info/RECORD +22 -0
- ha_deploy_kit-0.1.0.dist-info/WHEEL +4 -0
- ha_deploy_kit-0.1.0.dist-info/entry_points.txt +2 -0
- ha_deploy_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Optional hooks to serve a Lovelace card from a custom component.
|
|
2
|
+
|
|
3
|
+
Each hook probes the installed Home Assistant for the supported API and uses it,
|
|
4
|
+
rather than hard-coding a single call or swallowing unexpected errors blindly.
|
|
5
|
+
Both the legacy and current HA signatures are handled:
|
|
6
|
+
|
|
7
|
+
- static paths: legacy ``http.register_static_path`` or current
|
|
8
|
+
``http.async_register_static_paths`` with ``StaticPathConfig``;
|
|
9
|
+
- extra module URL: legacy ``frontend.add_extra_module_url`` or current
|
|
10
|
+
``frontend.add_extra_js_url`` (``es5=False`` registers an ES module).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from homeassistant.core import HomeAssistant
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_card_serving(
|
|
22
|
+
hass: HomeAssistant,
|
|
23
|
+
domain: str,
|
|
24
|
+
lovelace_dir: str,
|
|
25
|
+
card_url: str,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Serve ``lovelace_dir`` at ``card_url`` and load it as a frontend module."""
|
|
28
|
+
_register_static_path(hass, lovelace_dir, card_url)
|
|
29
|
+
_register_extra_module_url(hass, card_url)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _register_static_path(
|
|
33
|
+
hass: HomeAssistant, lovelace_dir: str, card_url: str
|
|
34
|
+
) -> None:
|
|
35
|
+
http = getattr(hass, "http", None)
|
|
36
|
+
if http is None:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
register_static = getattr(http, "register_static_path", None)
|
|
40
|
+
if callable(register_static):
|
|
41
|
+
register_static(card_url, lovelace_dir, False)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
async_register = getattr(http, "async_register_static_paths", None)
|
|
45
|
+
if callable(async_register):
|
|
46
|
+
from homeassistant.components.http import StaticPathConfig
|
|
47
|
+
|
|
48
|
+
hass.async_create_task(
|
|
49
|
+
async_register([StaticPathConfig(card_url, lovelace_dir, False)])
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _register_extra_module_url(hass: HomeAssistant, card_url: str) -> None:
|
|
54
|
+
from homeassistant.components import frontend
|
|
55
|
+
|
|
56
|
+
add_module = getattr(frontend, "add_extra_module_url", None)
|
|
57
|
+
if callable(add_module):
|
|
58
|
+
add_module(hass, card_url)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
add_js = getattr(frontend, "add_extra_js_url", None)
|
|
62
|
+
if callable(add_js):
|
|
63
|
+
add_js(hass, card_url)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Local inventory: pure, side-effect-free hashing of a component tree.
|
|
2
|
+
|
|
3
|
+
Keys are POSIX paths relative to ``component_dir`` (one namespace per
|
|
4
|
+
deployment root), so an unchanged tree compares as a true no-op.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ha_deploy_kit.settings import DeployError, DeploySettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sha256_file(path: Path) -> str:
|
|
16
|
+
"""Return the hex SHA-256 of a file, read in bounded chunks."""
|
|
17
|
+
digest = hashlib.sha256()
|
|
18
|
+
with path.open("rb") as handle:
|
|
19
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
20
|
+
digest.update(chunk)
|
|
21
|
+
return digest.hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def local_inventory(settings: DeploySettings) -> dict[str, str]:
|
|
25
|
+
"""Map component-relative POSIX paths to SHA-256.
|
|
26
|
+
|
|
27
|
+
Skips files whose suffix is in ``settings.skip_suffixes`` and any path with
|
|
28
|
+
a part in ``settings.skip_dirs``. Raises ``DeployError`` when the component
|
|
29
|
+
dir is missing or the resulting inventory is empty.
|
|
30
|
+
"""
|
|
31
|
+
component_dir = settings.component_dir
|
|
32
|
+
if not component_dir.is_dir():
|
|
33
|
+
raise DeployError(
|
|
34
|
+
f"component directory for domain '{settings.domain}' not found"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
inventory: dict[str, str] = {}
|
|
38
|
+
for path in sorted(component_dir.rglob("*")):
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
continue
|
|
41
|
+
rel = path.relative_to(component_dir)
|
|
42
|
+
if any(part in settings.skip_dirs for part in rel.parts):
|
|
43
|
+
continue
|
|
44
|
+
if path.suffix in settings.skip_suffixes:
|
|
45
|
+
continue
|
|
46
|
+
inventory[rel.as_posix()] = sha256_file(path)
|
|
47
|
+
|
|
48
|
+
if not inventory:
|
|
49
|
+
raise DeployError(f"no deployable files found for domain '{settings.domain}'")
|
|
50
|
+
return inventory
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Seed template for a consumer's custom_components/<domain>/services.yaml.
|
|
2
|
+
# Replace {service_name} with your configured service name (default below).
|
|
3
|
+
{service_name}:
|
|
4
|
+
name: Raise restart issue
|
|
5
|
+
description: Raise the restart-required repair issue for this integration.
|
|
6
|
+
fields: {}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"issues": {
|
|
3
|
+
"restart_required": {
|
|
4
|
+
"title": "Restart required to finish updating",
|
|
5
|
+
"fix_flow": {
|
|
6
|
+
"step": {
|
|
7
|
+
"confirm_restart": {
|
|
8
|
+
"title": "Restart Home Assistant",
|
|
9
|
+
"description": "Restart Home Assistant now to load the newly deployed code?"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"services": {
|
|
3
|
+
"raise_restart_issue": {
|
|
4
|
+
"name": "Raise restart issue",
|
|
5
|
+
"description": "Raise the restart-required repair issue for this integration."
|
|
6
|
+
}
|
|
7
|
+
},
|
|
8
|
+
"issues": {
|
|
9
|
+
"restart_required": {
|
|
10
|
+
"title": "Restart required to finish updating",
|
|
11
|
+
"description": "Newly deployed code is on disk. Restart Home Assistant to load it."
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
ha_deploy_kit/notify.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Sanitized HTTP notification client.
|
|
2
|
+
|
|
3
|
+
Posts to the HA service that raises the restart Repairs issue and classifies the
|
|
4
|
+
outcome into a fixed, secret-free vocabulary. Never prints tokens, raw bodies, or
|
|
5
|
+
secret-bearing exception text; never weakens TLS verification.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ssl
|
|
11
|
+
|
|
12
|
+
from ha_deploy_kit.settings import DeploySettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _verifying_ssl_context() -> ssl.SSLContext:
|
|
16
|
+
"""Return a strictly-verifying TLS context backed by a real CA store.
|
|
17
|
+
|
|
18
|
+
Python's default context has no usable CA store on some platforms (macOS /
|
|
19
|
+
python.org / fresh venvs), which misreports valid certs as ``tls-error``.
|
|
20
|
+
Prefer the OS trust store (``truststore``, matching ``curl``), then
|
|
21
|
+
``certifi``, then the stdlib default. Every branch keeps verification strict
|
|
22
|
+
(``CERT_REQUIRED`` + ``check_hostname``); none ever disables it.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
import truststore
|
|
26
|
+
|
|
27
|
+
return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
28
|
+
except ImportError:
|
|
29
|
+
pass
|
|
30
|
+
try:
|
|
31
|
+
import certifi
|
|
32
|
+
|
|
33
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
34
|
+
except ImportError:
|
|
35
|
+
pass
|
|
36
|
+
return ssl.create_default_context()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def raise_restart_issue(
|
|
40
|
+
base_url: str, token: str | None, settings: DeploySettings
|
|
41
|
+
) -> str:
|
|
42
|
+
"""POST ``{}`` to the restart service; return a sanitized status string.
|
|
43
|
+
|
|
44
|
+
One of: ``no-token``, ``raised``, ``no-service``, ``http-<code>``,
|
|
45
|
+
``tls-error``, ``connection-error``, ``invalid-config``, ``protocol-error``.
|
|
46
|
+
"""
|
|
47
|
+
import http.client
|
|
48
|
+
import urllib.error
|
|
49
|
+
import urllib.request
|
|
50
|
+
|
|
51
|
+
if not base_url or not token:
|
|
52
|
+
return "no-token"
|
|
53
|
+
|
|
54
|
+
context = _verifying_ssl_context()
|
|
55
|
+
|
|
56
|
+
url = f"{base_url}/api/services/{settings.domain}/{settings.service_name}"
|
|
57
|
+
request = urllib.request.Request(
|
|
58
|
+
url,
|
|
59
|
+
data=b"{}",
|
|
60
|
+
headers={
|
|
61
|
+
"Authorization": f"Bearer {token}",
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
},
|
|
64
|
+
method="POST",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(
|
|
69
|
+
request, timeout=settings.http_timeout, context=context
|
|
70
|
+
) as response:
|
|
71
|
+
response.read()
|
|
72
|
+
return "raised"
|
|
73
|
+
except urllib.error.HTTPError as err:
|
|
74
|
+
code = err.code
|
|
75
|
+
try:
|
|
76
|
+
err.read()
|
|
77
|
+
except Exception: # noqa: BLE001 - draining; outcome does not matter
|
|
78
|
+
pass
|
|
79
|
+
finally:
|
|
80
|
+
err.close()
|
|
81
|
+
if code in (400, 404) and _restart_service_missing(
|
|
82
|
+
base_url, token, settings, context
|
|
83
|
+
):
|
|
84
|
+
return "no-service"
|
|
85
|
+
return f"http-{code}"
|
|
86
|
+
except ssl.SSLCertVerificationError:
|
|
87
|
+
return "tls-error"
|
|
88
|
+
except urllib.error.URLError as err:
|
|
89
|
+
if isinstance(err.reason, ssl.SSLError):
|
|
90
|
+
return "tls-error"
|
|
91
|
+
return "connection-error"
|
|
92
|
+
except (ValueError, http.client.InvalidURL):
|
|
93
|
+
return "invalid-config"
|
|
94
|
+
except http.client.HTTPException:
|
|
95
|
+
return "protocol-error"
|
|
96
|
+
except OSError:
|
|
97
|
+
return "connection-error"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _restart_service_missing(
|
|
101
|
+
base_url: str, token: str, settings: DeploySettings, context: ssl.SSLContext
|
|
102
|
+
) -> bool:
|
|
103
|
+
"""Prove the service is absent via a bounded ``/api/services`` probe.
|
|
104
|
+
|
|
105
|
+
Returns ``True`` only when the response is a well-formed list and no item
|
|
106
|
+
declares ``settings.service_name`` under ``settings.domain``. Any malformed,
|
|
107
|
+
failed, or ambiguous probe returns ``False`` (keep the original HTTP error).
|
|
108
|
+
"""
|
|
109
|
+
import http.client
|
|
110
|
+
import json
|
|
111
|
+
import urllib.error
|
|
112
|
+
import urllib.request
|
|
113
|
+
|
|
114
|
+
url = f"{base_url}/api/services"
|
|
115
|
+
request = urllib.request.Request(
|
|
116
|
+
url,
|
|
117
|
+
headers={
|
|
118
|
+
"Authorization": f"Bearer {token}",
|
|
119
|
+
"Accept": "application/json",
|
|
120
|
+
},
|
|
121
|
+
method="GET",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
with urllib.request.urlopen(
|
|
126
|
+
request, timeout=settings.http_timeout, context=context
|
|
127
|
+
) as response:
|
|
128
|
+
raw = response.read()
|
|
129
|
+
data = json.loads(raw)
|
|
130
|
+
except (
|
|
131
|
+
urllib.error.HTTPError,
|
|
132
|
+
http.client.HTTPException,
|
|
133
|
+
urllib.error.URLError,
|
|
134
|
+
OSError,
|
|
135
|
+
ValueError,
|
|
136
|
+
):
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
if not isinstance(data, list):
|
|
140
|
+
return False
|
|
141
|
+
for item in data:
|
|
142
|
+
if not isinstance(item, dict):
|
|
143
|
+
return False
|
|
144
|
+
domain = item.get("domain")
|
|
145
|
+
services = item.get("services")
|
|
146
|
+
if not isinstance(domain, str) or not isinstance(services, dict):
|
|
147
|
+
return False
|
|
148
|
+
if domain == settings.domain and settings.service_name in services:
|
|
149
|
+
return False
|
|
150
|
+
return True
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Frozen settings types for ha_deploy_kit.
|
|
2
|
+
|
|
3
|
+
All per-consumer state travels in a frozen ``DeploySettings`` passed by
|
|
4
|
+
argument. No module globals, no ``os.environ`` mutation, and no imports of
|
|
5
|
+
Home Assistant: one process can deploy several consumers without cross-talk.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeployError(Exception):
|
|
15
|
+
"""Raised for any recoverable deployment error (sanitized messages)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class FrontendAsset:
|
|
20
|
+
"""An optional frontend card served separately from the backend tree.
|
|
21
|
+
|
|
22
|
+
``source_rel`` is POSIX, relative to the component dir (e.g.
|
|
23
|
+
``"lovelace/<name>-card.js"``); ``published_remote`` is the separately-served
|
|
24
|
+
copy relative to the remote HA config dir (e.g.
|
|
25
|
+
``"www/<domain>/<name>-card.js"``).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
source_rel: str
|
|
29
|
+
published_remote: str
|
|
30
|
+
serve_url: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class DeploySettings:
|
|
35
|
+
"""All per-consumer deployment state. Paths derive from ``source_root``."""
|
|
36
|
+
|
|
37
|
+
domain: str
|
|
38
|
+
source_root: Path
|
|
39
|
+
remote_config: str = "/config"
|
|
40
|
+
service_name: str = "raise_restart_issue"
|
|
41
|
+
issue_id: str = "restart_required"
|
|
42
|
+
translation_key: str = "restart_required"
|
|
43
|
+
learn_more_url: str | None = None
|
|
44
|
+
frontend: FrontendAsset | None = None
|
|
45
|
+
ssh_timeout: int = 120
|
|
46
|
+
http_timeout: int = 30
|
|
47
|
+
skip_dirs: frozenset[str] = frozenset({"__pycache__"})
|
|
48
|
+
skip_suffixes: tuple[str, ...] = (".pyc",)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def component_dir(self) -> Path:
|
|
52
|
+
return self.source_root / "custom_components" / self.domain
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def remote_component(self) -> str:
|
|
56
|
+
return f"{self.remote_config.rstrip('/')}/custom_components/{self.domain}"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""SSH command transport and safe tar streaming.
|
|
2
|
+
|
|
3
|
+
The transport never interpolates untrusted values into a shell string, never
|
|
4
|
+
issues ``ha core restart``, and validates every archive member before a single
|
|
5
|
+
byte is written.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import posixpath
|
|
11
|
+
import subprocess
|
|
12
|
+
import tarfile
|
|
13
|
+
from io import BytesIO
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from ha_deploy_kit.settings import DeployError, DeploySettings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _shquote(value: str) -> str:
|
|
20
|
+
"""Single-quote a value for safe embedding in a POSIX shell command."""
|
|
21
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SSHTransport(Protocol):
|
|
25
|
+
def __call__(self, command: str, stdin: bytes | None = None) -> bytes: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SubprocessSSH:
|
|
29
|
+
"""Run remote commands over ``ssh`` via ``subprocess`` (no shell on our side)."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, target: str, timeout: int = 120) -> None:
|
|
32
|
+
self.target = target
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
|
|
35
|
+
def __call__(self, command: str, stdin: bytes | None = None) -> bytes:
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["ssh", self.target, command],
|
|
39
|
+
input=stdin,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
timeout=self.timeout,
|
|
42
|
+
)
|
|
43
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
44
|
+
raise DeployError(f"SSH to {self.target} failed") from exc
|
|
45
|
+
if result.returncode != 0:
|
|
46
|
+
raise DeployError(
|
|
47
|
+
f"SSH to {self.target} failed with exit code {result.returncode}"
|
|
48
|
+
)
|
|
49
|
+
return result.stdout
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_members(settings: DeploySettings, rel_paths: list[str]) -> None:
|
|
53
|
+
"""Reject unsafe archive members before any transfer.
|
|
54
|
+
|
|
55
|
+
Rejects absolute paths, ``..`` traversal, NUL/newline injection, and any
|
|
56
|
+
source path that is or traverses a symlink escaping ``component_dir``.
|
|
57
|
+
Messages are sanitized and never echo the raw (attacker-controlled) path.
|
|
58
|
+
"""
|
|
59
|
+
base = settings.component_dir.resolve()
|
|
60
|
+
for index, rel in enumerate(rel_paths):
|
|
61
|
+
if "\x00" in rel or "\n" in rel or "\r" in rel:
|
|
62
|
+
raise DeployError(f"archive member #{index} contains an illegal character")
|
|
63
|
+
if rel.startswith("/") or posixpath.isabs(rel):
|
|
64
|
+
raise DeployError(f"archive member #{index} is an absolute path")
|
|
65
|
+
if ".." in rel.split("/"):
|
|
66
|
+
raise DeployError(f"archive member #{index} traverses outside the tree")
|
|
67
|
+
resolved = (settings.component_dir / rel).resolve()
|
|
68
|
+
try:
|
|
69
|
+
resolved.relative_to(base)
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise DeployError(
|
|
72
|
+
f"archive member #{index} escapes the component directory"
|
|
73
|
+
) from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_tar(settings: DeploySettings, rel_paths: list[str]) -> bytes:
|
|
77
|
+
"""Build an uncompressed tar of the given members (owner-normalized)."""
|
|
78
|
+
validate_members(settings, rel_paths)
|
|
79
|
+
|
|
80
|
+
def _normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
|
|
81
|
+
info.uid = 0
|
|
82
|
+
info.gid = 0
|
|
83
|
+
info.uname = ""
|
|
84
|
+
info.gname = ""
|
|
85
|
+
return info
|
|
86
|
+
|
|
87
|
+
buffer = BytesIO()
|
|
88
|
+
with tarfile.open(fileobj=buffer, mode="w") as tar:
|
|
89
|
+
for rel in rel_paths:
|
|
90
|
+
tar.add(
|
|
91
|
+
settings.component_dir / rel,
|
|
92
|
+
arcname=rel,
|
|
93
|
+
recursive=False,
|
|
94
|
+
filter=_normalize,
|
|
95
|
+
)
|
|
96
|
+
return buffer.getvalue()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def deploy_backend(
|
|
100
|
+
ssh: SSHTransport, settings: DeploySettings, rel_paths: list[str]
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Create the remote dirs and stream the tar. No atomicity, no rollback."""
|
|
103
|
+
remote = settings.remote_component
|
|
104
|
+
dirs = [remote, f"{remote}/translations"]
|
|
105
|
+
if settings.frontend is not None:
|
|
106
|
+
published = (
|
|
107
|
+
settings.remote_config.rstrip("/")
|
|
108
|
+
+ "/"
|
|
109
|
+
+ settings.frontend.published_remote
|
|
110
|
+
)
|
|
111
|
+
dirs.append(posixpath.dirname(published))
|
|
112
|
+
|
|
113
|
+
mkdir = "mkdir -p " + " ".join(_shquote(d) for d in dirs)
|
|
114
|
+
ssh(mkdir)
|
|
115
|
+
|
|
116
|
+
blob = build_tar(settings, rel_paths)
|
|
117
|
+
ssh(f"tar -xf - -C {_shquote(remote)}", stdin=blob)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ha-deploy-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HA-free SSH deploy / compare / notify / optional-frontend toolkit for Home Assistant custom components
|
|
5
|
+
Project-URL: Homepage, https://github.com/latrekc-meta/ha-deploy-kit
|
|
6
|
+
Project-URL: Repository, https://github.com/latrekc-meta/ha-deploy-kit
|
|
7
|
+
Project-URL: Issues, https://github.com/latrekc-meta/ha-deploy-kit/issues
|
|
8
|
+
Author: latrekc-meta
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 latrekc-meta
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: custom-component,deploy,home-assistant,homeassistant,lovelace,ssh
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Topic :: Home Automation
|
|
41
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
42
|
+
Requires-Python: >=3.10
|
|
43
|
+
Requires-Dist: certifi
|
|
44
|
+
Requires-Dist: truststore>=0.9
|
|
45
|
+
Provides-Extra: ha
|
|
46
|
+
Requires-Dist: homeassistant; extra == 'ha'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# ha-deploy-kit
|
|
50
|
+
|
|
51
|
+
A small, reusable library for deploying a Home Assistant custom integration's
|
|
52
|
+
code to a running HA host over SSH, and (optionally) wiring the HA-side restart
|
|
53
|
+
notification. The core is Home-Assistant-free and standard-library only, so a
|
|
54
|
+
deploy script can import it without pulling in Home Assistant; the HA adapter is
|
|
55
|
+
a separate, explicitly-imported subpackage.
|
|
56
|
+
|
|
57
|
+
One process can deploy several integrations without cross-talk: all per-consumer
|
|
58
|
+
state lives in a frozen `DeploySettings` passed by argument — no module globals
|
|
59
|
+
and no `os.environ` mutation.
|
|
60
|
+
|
|
61
|
+
## What it does
|
|
62
|
+
|
|
63
|
+
- Builds a content-hash inventory of your `custom_components/<domain>` tree and
|
|
64
|
+
compares it to the remote tree, deploying only the changed files.
|
|
65
|
+
- Leaves remote-only files untouched (never deletes on the host).
|
|
66
|
+
- Streams files over SSH with an uncompressed, path-validated tar — no backups,
|
|
67
|
+
no lock files, no rollback, no auto-restart.
|
|
68
|
+
- Optionally publishes a separately-served Lovelace card.
|
|
69
|
+
- Optionally raises a Home Assistant *Repairs* issue (via an HA service) telling
|
|
70
|
+
the user a restart is needed. The engine never restarts HA itself; restart is
|
|
71
|
+
opt-in and happens only when a user confirms a Repairs fix flow.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
Published on PyPI. Pin an exact version.
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
pip install "ha-deploy-kit==0.1.0"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The HA adapter needs Home Assistant, which is an optional extra:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
pip install "ha-deploy-kit[ha]==0.1.0"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **Core** requires Python **3.10+** and only the standard library (plus a CA
|
|
88
|
+
trust store: `certifi`/`truststore`).
|
|
89
|
+
- **The `ha` adapter** requires the Python version Home Assistant itself
|
|
90
|
+
requires; install it in the HA runtime, not in your deploy tooling.
|
|
91
|
+
- **Home Assistant integrations** must pin a versioned PyPI requirement
|
|
92
|
+
(`"ha-deploy-kit==0.1.0"` in `manifest.json`), **never** a `git+…` VCS URL — HA
|
|
93
|
+
installs requirements in its own credential-less environment and treats URL
|
|
94
|
+
requirements as never-satisfied, retrying every startup.
|
|
95
|
+
|
|
96
|
+
See [docs/install.md](docs/install.md) for `pyproject.toml`, HA `manifest.json`,
|
|
97
|
+
and air-gapped host instructions, and [docs/releasing.md](docs/releasing.md) for
|
|
98
|
+
how releases are published.
|
|
99
|
+
|
|
100
|
+
## Public API
|
|
101
|
+
|
|
102
|
+
| Symbol | Import | Purpose |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| `DeploySettings` | `ha_deploy_kit` | Frozen per-consumer settings (domain, source root, remote config, optional frontend, timeouts). |
|
|
105
|
+
| `FrontendAsset` | `ha_deploy_kit` | Optional served-card mapping (`source_rel`, `published_remote`). |
|
|
106
|
+
| `DeployError` | `ha_deploy_kit` | Raised for recoverable, sanitized deployment errors. |
|
|
107
|
+
| `run(settings, argv, ssh=None, environ=None)` | `ha_deploy_kit.cli` | Orchestrate end to end: compare, report, publish the changed card, deploy the changed backend, notify. Honors a `REMOTE_CONFIG` env override. |
|
|
108
|
+
| `raise_restart_issue(base_url, token, settings)` | `ha_deploy_kit.notify` | POST to the HA restart service; returns a sanitized status string. |
|
|
109
|
+
| `frontend_run(settings, plan, ssh, *, check)` | `ha_deploy_kit.frontend` | Deploy the card into the tree and/or publish the served copy. |
|
|
110
|
+
| `ha.async_register_restart_service(hass, domain, ...)` | `ha_deploy_kit.ha` | Idempotently register the restart service (empty schema). Pass `fixable=True` for a one-click Repairs card (pair with the fix flow). |
|
|
111
|
+
| `ha.async_raise_restart_issue(hass, domain, ...)` | `ha_deploy_kit.ha` | Raise the deduplicated, non-persistent restart Repairs issue. |
|
|
112
|
+
| `ha.async_clear_restart_issue(hass, domain, ...)` | `ha_deploy_kit.ha` | Clear the restart issue. |
|
|
113
|
+
| `ha.async_create_restart_fix_flow(hass, issue_id, ...)` | `ha_deploy_kit.ha` | Optional opt-in confirm-and-restart flow (restart only on submit). |
|
|
114
|
+
| `ha.register_card_serving(hass, domain, lovelace_dir, card_url)` | `ha_deploy_kit.ha` | Serve the card and register it as a frontend module. |
|
|
115
|
+
|
|
116
|
+
## CLI
|
|
117
|
+
|
|
118
|
+
The package installs a `ha-deploy` console script. It deploys by default; pass
|
|
119
|
+
`--check` (or its alias `--dry-run`) to preview without writing anything.
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
ha-deploy # deploy changed backend files, then notify
|
|
123
|
+
ha-deploy --check # compare and report only; no writes, no notify
|
|
124
|
+
ha-deploy --root /path/to/your/repo
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The source root is resolved from `--root`, then `HA_DEPLOY_SOURCE_ROOT`, then
|
|
128
|
+
the current directory. The domain is read from `HA_DEPLOY_DOMAIN`, or inferred
|
|
129
|
+
when the root has exactly one `custom_components/<domain>` subdirectory.
|
|
130
|
+
|
|
131
|
+
### One-liner for your own deploy script
|
|
132
|
+
|
|
133
|
+
`run` is fully frontend-aware, so a consumer's `deploy.py` is just settings + a
|
|
134
|
+
call — no pipeline to copy. It publishes a changed card and deploys a changed
|
|
135
|
+
backend; a frontend-only change publishes the card but never notifies a restart.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import sys
|
|
139
|
+
from pathlib import Path
|
|
140
|
+
|
|
141
|
+
from ha_deploy_kit import DeploySettings, FrontendAsset
|
|
142
|
+
from ha_deploy_kit.cli import run
|
|
143
|
+
|
|
144
|
+
settings = DeploySettings(
|
|
145
|
+
domain="your_domain",
|
|
146
|
+
source_root=Path(__file__).resolve().parent,
|
|
147
|
+
frontend=FrontendAsset( # omit entirely for backend-only
|
|
148
|
+
source_rel="lovelace/your-card.js",
|
|
149
|
+
published_remote="www/your_domain/your-card.js",
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
raise SystemExit(run(settings, sys.argv[1:]))
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`TARGET`/`HA_BASE_URL`/`HA_TOKEN` and an optional `REMOTE_CONFIG` override come
|
|
156
|
+
from `.env` or the process environment; `run` applies `REMOTE_CONFIG` for you.
|
|
157
|
+
|
|
158
|
+
See [docs/configuration.md](docs/configuration.md) for settings and `.env`, and
|
|
159
|
+
[docs/adopting.md](docs/adopting.md) for a step-by-step adoption guide.
|
|
160
|
+
|
|
161
|
+
## Design notes
|
|
162
|
+
|
|
163
|
+
This library deliberately differs from typical hand-rolled HA deploy scripts.
|
|
164
|
+
Each choice below is intentional:
|
|
165
|
+
|
|
166
|
+
- **No positional target and no `--restart` / `--no-restart` / `--card-only`
|
|
167
|
+
flags.** The target comes from the environment; the engine never restarts HA,
|
|
168
|
+
so there is no restart flag to pass.
|
|
169
|
+
- **The engine never executes a restart.** It can only raise a Repairs issue;
|
|
170
|
+
the actual restart is opt-in behind a user-confirmed fix flow.
|
|
171
|
+
- **A frontend-only change never notifies a backend restart.** Backend and
|
|
172
|
+
frontend are separate concerns.
|
|
173
|
+
- **Structured remote inventory.** The remote probe returns an explicit
|
|
174
|
+
four-field result (hashes, stale files, a missing-tree flag, published hash)
|
|
175
|
+
rather than guessing from text.
|
|
176
|
+
- **Environment precedence is explicit.** Process environment overrides `.env`;
|
|
177
|
+
the library never mutates `os.environ`.
|
|
178
|
+
- **Path validation.** Tar members are rejected if absolute, containing `..`,
|
|
179
|
+
NUL, or newlines, or escaping the component directory.
|
|
180
|
+
- **No backups, lock files, rollback, or auto-restart**, and tokens, raw bodies,
|
|
181
|
+
and secret-bearing errors are never printed. TLS and SSH verification are
|
|
182
|
+
never weakened.
|
|
183
|
+
- **Real CA trust store for the notification.** The HTTPS call to Home Assistant
|
|
184
|
+
verifies against the OS trust store via `truststore` (matching `curl`), falling
|
|
185
|
+
back to `certifi` then the stdlib default — all strictly verifying
|
|
186
|
+
(`CERT_REQUIRED` + hostname check). This avoids misreporting a valid cert as
|
|
187
|
+
`tls-error` on platforms whose default context has no usable CA store (macOS,
|
|
188
|
+
python.org, fresh venvs); `tls-error` is reserved for genuine failures.
|
|
189
|
+
|
|
190
|
+
## Releases
|
|
191
|
+
|
|
192
|
+
Releases are automated: every merge to `main` runs semantic-release, which picks
|
|
193
|
+
the next version from the [Conventional Commit] messages (`fix:` → patch,
|
|
194
|
+
`feat:` → minor, `feat!:`/`BREAKING CHANGE:` → major; `docs:`/`chore:`/`test:`
|
|
195
|
+
publish nothing) and publishes to PyPI via Trusted Publishing. Write PR
|
|
196
|
+
titles/commits in that form. See [docs/releasing.md](docs/releasing.md) for the
|
|
197
|
+
one-time setup and the manual fallback.
|
|
198
|
+
|
|
199
|
+
[Conventional Commit]: https://www.conventionalcommits.org/
|