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,9 @@
|
|
|
1
|
+
"""ha_deploy_kit: HA-free SSH deploy / compare / notify toolkit.
|
|
2
|
+
|
|
3
|
+
The core package never imports Home Assistant. The HA-side adapter lives in
|
|
4
|
+
``ha_deploy_kit.ha`` and must be imported explicitly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ha_deploy_kit.settings import DeployError, DeploySettings, FrontendAsset
|
|
8
|
+
|
|
9
|
+
__all__ = ["DeployError", "DeploySettings", "FrontendAsset"]
|
ha_deploy_kit/cli.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""CLI orchestration and sanitized reporting.
|
|
2
|
+
|
|
3
|
+
``run`` is the testable entrypoint: resolve env, build the local inventory, probe
|
|
4
|
+
the remote, compare, report, and — only when the backend changed — deploy the
|
|
5
|
+
changed subset and attempt the restart notification. A frontend-only change does
|
|
6
|
+
no backend deploy/mkdir/notify (the frontend profile handles the card). The
|
|
7
|
+
engine never restarts; it only ever prints a manual-restart line as guidance.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ha_deploy_kit.comparison import ComparisonPlan, compare, fetch_remote_inventory
|
|
19
|
+
from ha_deploy_kit.config import api_base, env_value, resolve_env
|
|
20
|
+
from ha_deploy_kit.frontend import frontend_run
|
|
21
|
+
from ha_deploy_kit.inventory import local_inventory
|
|
22
|
+
from ha_deploy_kit.notify import raise_restart_issue
|
|
23
|
+
from ha_deploy_kit.settings import DeployError, DeploySettings
|
|
24
|
+
from ha_deploy_kit.transport import SSHTransport, SubprocessSSH, deploy_backend
|
|
25
|
+
|
|
26
|
+
_STATUS_MESSAGES = {
|
|
27
|
+
"raised": "Restart notification raised.",
|
|
28
|
+
"no-token": "No API token configured; notification skipped.",
|
|
29
|
+
"no-service": "Restart service not registered on Home Assistant.",
|
|
30
|
+
"tls-error": "Could not reach Home Assistant: TLS verification failed.",
|
|
31
|
+
"connection-error": "Could not reach Home Assistant: connection failed.",
|
|
32
|
+
"invalid-config": "Invalid Home Assistant URL configuration.",
|
|
33
|
+
"protocol-error": "Unexpected protocol error contacting Home Assistant.",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
38
|
+
parser = argparse.ArgumentParser(prog="ha-deploy")
|
|
39
|
+
parser.add_argument("--check", action="store_true", dest="check")
|
|
40
|
+
parser.add_argument("--dry-run", action="store_true", dest="check")
|
|
41
|
+
parser.add_argument("--root", default=None)
|
|
42
|
+
return parser.parse_args(argv)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _notify_message(status: str, target: str) -> str:
|
|
46
|
+
base = _STATUS_MESSAGES.get(status)
|
|
47
|
+
if base is None:
|
|
48
|
+
base = f"Home Assistant returned {status}."
|
|
49
|
+
manual = f"Restart manually: ssh {target} 'ha core restart'"
|
|
50
|
+
return f"{base} {manual}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def report(settings: DeploySettings, plan: ComparisonPlan, target: str) -> None:
|
|
54
|
+
print(f"Target: {target} (domain: {settings.domain})")
|
|
55
|
+
if plan.in_sync:
|
|
56
|
+
print("Backend: in sync.")
|
|
57
|
+
elif plan.changed_backend:
|
|
58
|
+
print(f"Backend: {len(plan.changed_backend)} file(s) to deploy.")
|
|
59
|
+
else:
|
|
60
|
+
print("Backend: no changes.")
|
|
61
|
+
if plan.stale_remote:
|
|
62
|
+
print(f"Remote-only files (left untouched): {len(plan.stale_remote)}.")
|
|
63
|
+
if settings.frontend is not None:
|
|
64
|
+
if plan.frontend_changed:
|
|
65
|
+
print("Frontend: card changes pending.")
|
|
66
|
+
else:
|
|
67
|
+
print("Frontend: in sync.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run(
|
|
71
|
+
settings: DeploySettings,
|
|
72
|
+
argv: list[str],
|
|
73
|
+
ssh: SSHTransport | None = None,
|
|
74
|
+
environ: Mapping[str, str] | None = None,
|
|
75
|
+
) -> int:
|
|
76
|
+
args = parse_args(argv)
|
|
77
|
+
environ = os.environ if environ is None else environ
|
|
78
|
+
|
|
79
|
+
resolved = resolve_env(settings.source_root / ".env", environ)
|
|
80
|
+
base = api_base(resolved)
|
|
81
|
+
token = env_value(resolved, "HA_TOKEN")
|
|
82
|
+
target = env_value(resolved, "TARGET")
|
|
83
|
+
|
|
84
|
+
remote_config = env_value(resolved, "REMOTE_CONFIG")
|
|
85
|
+
if remote_config:
|
|
86
|
+
settings = replace(settings, remote_config=remote_config)
|
|
87
|
+
|
|
88
|
+
if ssh is None:
|
|
89
|
+
ssh = SubprocessSSH(target, settings.ssh_timeout)
|
|
90
|
+
|
|
91
|
+
local = local_inventory(settings)
|
|
92
|
+
rel_paths = sorted(local)
|
|
93
|
+
remote = fetch_remote_inventory(ssh, settings, rel_paths)
|
|
94
|
+
plan = compare(local, remote, settings)
|
|
95
|
+
report(settings, plan, target)
|
|
96
|
+
|
|
97
|
+
if plan.in_sync:
|
|
98
|
+
print("Already in sync; nothing to do.")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
# Publish the card (or preview on --check). No-op when frontend is None or
|
|
102
|
+
# unchanged; a frontend-only change publishes but never notifies a restart.
|
|
103
|
+
for note in frontend_run(settings, plan, ssh, check=args.check):
|
|
104
|
+
print(note)
|
|
105
|
+
|
|
106
|
+
if args.check:
|
|
107
|
+
print("Check only: no changes applied.")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
if plan.changed_backend:
|
|
111
|
+
try:
|
|
112
|
+
deploy_backend(ssh, settings, plan.changed_backend)
|
|
113
|
+
except DeployError as exc:
|
|
114
|
+
print(f"Deployment failed: {exc}")
|
|
115
|
+
return 1
|
|
116
|
+
status = raise_restart_issue(base, token, settings)
|
|
117
|
+
print(_notify_message(status, target))
|
|
118
|
+
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str] | None = None) -> int:
|
|
123
|
+
args = parse_args(argv)
|
|
124
|
+
root = args.root or os.environ.get("HA_DEPLOY_SOURCE_ROOT") or os.getcwd()
|
|
125
|
+
source_root = Path(root)
|
|
126
|
+
|
|
127
|
+
domain = os.environ.get("HA_DEPLOY_DOMAIN")
|
|
128
|
+
if not domain:
|
|
129
|
+
components = source_root / "custom_components"
|
|
130
|
+
candidates = (
|
|
131
|
+
[p.name for p in sorted(components.iterdir()) if p.is_dir()]
|
|
132
|
+
if components.is_dir()
|
|
133
|
+
else []
|
|
134
|
+
)
|
|
135
|
+
if len(candidates) != 1:
|
|
136
|
+
print(
|
|
137
|
+
"Set HA_DEPLOY_DOMAIN: could not infer a single component under "
|
|
138
|
+
f"{components}"
|
|
139
|
+
)
|
|
140
|
+
return 2
|
|
141
|
+
domain = candidates[0]
|
|
142
|
+
|
|
143
|
+
settings = DeploySettings(domain=domain, source_root=source_root)
|
|
144
|
+
return run(settings, argv or [])
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Remote inventory wire format and comparison planning (pure).
|
|
2
|
+
|
|
3
|
+
The remote probe is a single one-shot shell script whose output is parsed into a
|
|
4
|
+
structured ``RemoteInventory`` with an explicit ``missing_tree`` flag. ``compare``
|
|
5
|
+
diffs that against the local inventory and produces a ``ComparisonPlan``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from ha_deploy_kit.settings import DeployError, DeploySettings
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ha_deploy_kit.transport import SSHTransport
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _shquote(value: str) -> str:
|
|
20
|
+
"""Single-quote a value for safe embedding in a POSIX shell script."""
|
|
21
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class RemoteInventory:
|
|
26
|
+
"""Parsed result of the remote probe.
|
|
27
|
+
|
|
28
|
+
``missing_tree`` is explicit (corrects the reference 3-vs-4 return mismatch):
|
|
29
|
+
an absent component dir is a distinct state from an empty hash map.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
hashes: dict[str, str]
|
|
33
|
+
stale: list[str] = field(default_factory=list)
|
|
34
|
+
missing_tree: bool = False
|
|
35
|
+
published_hash: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ComparisonPlan:
|
|
40
|
+
changed_backend: list[str]
|
|
41
|
+
stale_remote: list[str]
|
|
42
|
+
frontend_tree_changed: bool = False
|
|
43
|
+
frontend_published_changed: bool = False
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def frontend_changed(self) -> bool:
|
|
47
|
+
return self.frontend_tree_changed or self.frontend_published_changed
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def in_sync(self) -> bool:
|
|
51
|
+
return not self.changed_backend and not self.frontend_changed
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_inventory_script(settings: DeploySettings, rel_paths: list[str]) -> str:
|
|
55
|
+
"""Build the one-shot remote probe script.
|
|
56
|
+
|
|
57
|
+
Reads requested relative paths from stdin; emits ``sha256sum``/``MISSING <f>``
|
|
58
|
+
per requested path, ``REMOTE_ONLY <p>`` for every remote file (caches
|
|
59
|
+
excluded), ``NO_REMOTE_TREE`` when the component dir is absent, and
|
|
60
|
+
``WWW:card``/``MISSING WWW:card`` for a configured frontend published copy.
|
|
61
|
+
"""
|
|
62
|
+
remote = _shquote(settings.remote_component)
|
|
63
|
+
parts = [
|
|
64
|
+
"set -u",
|
|
65
|
+
f"DIR={remote}",
|
|
66
|
+
'if [ ! -d "$DIR" ]; then',
|
|
67
|
+
" echo NO_REMOTE_TREE",
|
|
68
|
+
"else",
|
|
69
|
+
" while IFS= read -r f; do",
|
|
70
|
+
' [ -z "$f" ] && continue',
|
|
71
|
+
' if [ -f "$DIR/$f" ]; then',
|
|
72
|
+
' (cd "$DIR" && sha256sum "$f")',
|
|
73
|
+
" else",
|
|
74
|
+
' echo "MISSING $f"',
|
|
75
|
+
" fi",
|
|
76
|
+
" done",
|
|
77
|
+
(
|
|
78
|
+
' find "$DIR" -type f ! -path "*/__pycache__/*" '
|
|
79
|
+
'! -name "*.pyc" 2>/dev/null \\'
|
|
80
|
+
),
|
|
81
|
+
' | sed "s#^$DIR/##" | sort | while IFS= read -r p; do',
|
|
82
|
+
' echo "REMOTE_ONLY $p"',
|
|
83
|
+
" done",
|
|
84
|
+
"fi",
|
|
85
|
+
]
|
|
86
|
+
if settings.frontend is not None:
|
|
87
|
+
published = (
|
|
88
|
+
settings.remote_config.rstrip("/")
|
|
89
|
+
+ "/"
|
|
90
|
+
+ settings.frontend.published_remote
|
|
91
|
+
)
|
|
92
|
+
card = _shquote(published)
|
|
93
|
+
parts += [
|
|
94
|
+
f"CARD={card}",
|
|
95
|
+
'if [ -f "$CARD" ]; then',
|
|
96
|
+
' echo "$(sha256sum "$CARD" | cut -d\' \' -f1) WWW:card"',
|
|
97
|
+
"else",
|
|
98
|
+
" echo MISSING WWW:card",
|
|
99
|
+
"fi",
|
|
100
|
+
]
|
|
101
|
+
return "\n".join(parts) + "\n"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_inventory(
|
|
105
|
+
output: str, rel_paths: list[str], settings: DeploySettings
|
|
106
|
+
) -> RemoteInventory:
|
|
107
|
+
"""Parse the remote probe output into a ``RemoteInventory``.
|
|
108
|
+
|
|
109
|
+
``REMOTE_ONLY`` entries that correspond to a requested (local) path are not
|
|
110
|
+
stale. Raises ``DeployError`` on empty output.
|
|
111
|
+
"""
|
|
112
|
+
if not output.strip():
|
|
113
|
+
raise DeployError("empty remote inventory output")
|
|
114
|
+
|
|
115
|
+
requested = set(rel_paths)
|
|
116
|
+
hashes: dict[str, str] = {}
|
|
117
|
+
stale: list[str] = []
|
|
118
|
+
missing_tree = False
|
|
119
|
+
published_hash: str | None = None
|
|
120
|
+
|
|
121
|
+
for raw in output.splitlines():
|
|
122
|
+
line = raw.strip()
|
|
123
|
+
if not line:
|
|
124
|
+
continue
|
|
125
|
+
if line == "NO_REMOTE_TREE":
|
|
126
|
+
missing_tree = True
|
|
127
|
+
elif line == "MISSING WWW:card":
|
|
128
|
+
published_hash = None
|
|
129
|
+
elif line.endswith("WWW:card"):
|
|
130
|
+
published_hash = line.split()[0]
|
|
131
|
+
elif line.startswith("MISSING "):
|
|
132
|
+
hashes[line[len("MISSING ") :]] = ""
|
|
133
|
+
elif line.startswith("REMOTE_ONLY "):
|
|
134
|
+
path = line[len("REMOTE_ONLY ") :]
|
|
135
|
+
if path not in requested:
|
|
136
|
+
stale.append(path)
|
|
137
|
+
else:
|
|
138
|
+
parts = line.split()
|
|
139
|
+
if len(parts) >= 2 and len(parts[0]) == 64:
|
|
140
|
+
hashes[parts[-1]] = parts[0]
|
|
141
|
+
# anything else is noise and ignored
|
|
142
|
+
|
|
143
|
+
return RemoteInventory(
|
|
144
|
+
hashes=hashes,
|
|
145
|
+
stale=stale,
|
|
146
|
+
missing_tree=missing_tree,
|
|
147
|
+
published_hash=published_hash,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def fetch_remote_inventory(
|
|
152
|
+
ssh: SSHTransport, settings: DeploySettings, rel_paths: list[str]
|
|
153
|
+
) -> RemoteInventory:
|
|
154
|
+
"""Bridge transport to pure parsing: send the probe, parse the result."""
|
|
155
|
+
script = build_inventory_script(settings, rel_paths)
|
|
156
|
+
stdin = ("\n".join(rel_paths) + "\n").encode()
|
|
157
|
+
output = ssh(script, stdin=stdin)
|
|
158
|
+
return parse_inventory(output.decode(), rel_paths, settings)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def compare(
|
|
162
|
+
local: dict[str, str], remote: RemoteInventory, settings: DeploySettings
|
|
163
|
+
) -> ComparisonPlan:
|
|
164
|
+
"""Diff local vs remote into a ``ComparisonPlan``.
|
|
165
|
+
|
|
166
|
+
The frontend ``source_rel`` is excluded from ``changed_backend`` and tracked
|
|
167
|
+
separately. A configured-but-missing local card raises ``DeployError`` before
|
|
168
|
+
any write; an absent optional asset never raises.
|
|
169
|
+
"""
|
|
170
|
+
frontend = settings.frontend
|
|
171
|
+
source_rel = frontend.source_rel if frontend is not None else None
|
|
172
|
+
|
|
173
|
+
if source_rel is not None and source_rel not in local:
|
|
174
|
+
raise DeployError(
|
|
175
|
+
f"configured frontend source '{source_rel}' not found in local tree"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
backend_local = {k: v for k, v in local.items() if k != source_rel}
|
|
179
|
+
|
|
180
|
+
if remote.missing_tree:
|
|
181
|
+
changed_backend = sorted(backend_local)
|
|
182
|
+
else:
|
|
183
|
+
changed_backend = [
|
|
184
|
+
k for k in sorted(backend_local) if remote.hashes.get(k) != backend_local[k]
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
frontend_tree_changed = False
|
|
188
|
+
frontend_published_changed = False
|
|
189
|
+
if source_rel is not None:
|
|
190
|
+
local_card = local[source_rel]
|
|
191
|
+
if remote.missing_tree:
|
|
192
|
+
frontend_tree_changed = True
|
|
193
|
+
else:
|
|
194
|
+
frontend_tree_changed = remote.hashes.get(source_rel) != local_card
|
|
195
|
+
frontend_published_changed = remote.published_hash != local_card
|
|
196
|
+
|
|
197
|
+
return ComparisonPlan(
|
|
198
|
+
changed_backend=changed_backend,
|
|
199
|
+
stale_remote=list(remote.stale),
|
|
200
|
+
frontend_tree_changed=frontend_tree_changed,
|
|
201
|
+
frontend_published_changed=frontend_published_changed,
|
|
202
|
+
)
|
ha_deploy_kit/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Configuration loading without global mutation.
|
|
2
|
+
|
|
3
|
+
``load_dotenv`` returns the parsed values and never touches ``os.environ`` (the
|
|
4
|
+
reference scripts mutated it, which leaks between two consumers in one process).
|
|
5
|
+
``resolve_env`` merges with process env taking precedence over the file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
15
|
+
_API_BASE_KEY = "HA_BASE_URL"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_dotenv(path: Path) -> dict[str, str]:
|
|
19
|
+
"""Parse a ``KEY=VALUE`` file into a dict without mutating ``os.environ``.
|
|
20
|
+
|
|
21
|
+
Ignores blanks and ``#`` comments, strips a leading ``export``, strips one
|
|
22
|
+
layer of matching single/double quotes, keeps empty values, accepts only
|
|
23
|
+
``[A-Za-z_][A-Za-z0-9_]*`` keys, ignores malformed lines, and performs no
|
|
24
|
+
shell evaluation. A missing file yields ``{}``.
|
|
25
|
+
"""
|
|
26
|
+
if not path.is_file():
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
result: dict[str, str] = {}
|
|
30
|
+
for raw in path.read_text().splitlines():
|
|
31
|
+
line = raw.strip()
|
|
32
|
+
if not line or line.startswith("#"):
|
|
33
|
+
continue
|
|
34
|
+
if line.startswith("export "):
|
|
35
|
+
line = line[len("export ") :].lstrip()
|
|
36
|
+
if "=" not in line:
|
|
37
|
+
continue
|
|
38
|
+
key, _, value = line.partition("=")
|
|
39
|
+
key = key.strip()
|
|
40
|
+
if not _KEY_RE.fullmatch(key):
|
|
41
|
+
continue
|
|
42
|
+
value = value.strip()
|
|
43
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
44
|
+
value = value[1:-1]
|
|
45
|
+
result[key] = value
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_env(path: Path, environ: Mapping[str, str]) -> dict[str, str]:
|
|
50
|
+
"""Merge file values with process env, where process env wins."""
|
|
51
|
+
resolved = load_dotenv(path)
|
|
52
|
+
resolved.update(environ)
|
|
53
|
+
return resolved
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def env_value(resolved: Mapping[str, str], key: str) -> str:
|
|
57
|
+
"""Return a stripped value for ``key`` (empty string when absent)."""
|
|
58
|
+
return resolved.get(key, "").strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def api_base(resolved: Mapping[str, str]) -> str:
|
|
62
|
+
"""Return the HA base URL, stripped and without a trailing slash."""
|
|
63
|
+
return env_value(resolved, _API_BASE_KEY).rstrip("/")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Optional frontend profile: publish a served card copy and run frontend steps.
|
|
2
|
+
|
|
3
|
+
Frontend copying never triggers a backend restart notification and never adds a
|
|
4
|
+
JS build system. It only streams the card file via the same validated tar path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import posixpath
|
|
10
|
+
import tarfile
|
|
11
|
+
from io import BytesIO
|
|
12
|
+
|
|
13
|
+
from ha_deploy_kit.comparison import ComparisonPlan
|
|
14
|
+
from ha_deploy_kit.settings import DeploySettings
|
|
15
|
+
from ha_deploy_kit.transport import (
|
|
16
|
+
SSHTransport,
|
|
17
|
+
_shquote,
|
|
18
|
+
deploy_backend,
|
|
19
|
+
validate_members,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def publish_frontend(ssh: SSHTransport, settings: DeploySettings) -> None:
|
|
24
|
+
"""Stream the card to its separately-served published location."""
|
|
25
|
+
frontend = settings.frontend
|
|
26
|
+
if frontend is None:
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
validate_members(settings, [frontend.source_rel])
|
|
30
|
+
published = settings.remote_config.rstrip("/") + "/" + frontend.published_remote
|
|
31
|
+
published_dir = posixpath.dirname(published)
|
|
32
|
+
arcname = posixpath.basename(published)
|
|
33
|
+
|
|
34
|
+
def _normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
|
|
35
|
+
info.uid = 0
|
|
36
|
+
info.gid = 0
|
|
37
|
+
info.uname = ""
|
|
38
|
+
info.gname = ""
|
|
39
|
+
return info
|
|
40
|
+
|
|
41
|
+
buffer = BytesIO()
|
|
42
|
+
with tarfile.open(fileobj=buffer, mode="w") as tar:
|
|
43
|
+
tar.add(
|
|
44
|
+
settings.component_dir / frontend.source_rel,
|
|
45
|
+
arcname=arcname,
|
|
46
|
+
recursive=False,
|
|
47
|
+
filter=_normalize,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
ssh("mkdir -p " + _shquote(published_dir))
|
|
51
|
+
ssh(f"tar -xf - -C {_shquote(published_dir)}", stdin=buffer.getvalue())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def frontend_run(
|
|
55
|
+
settings: DeploySettings,
|
|
56
|
+
plan: ComparisonPlan,
|
|
57
|
+
ssh: SSHTransport,
|
|
58
|
+
*,
|
|
59
|
+
check: bool,
|
|
60
|
+
) -> list[str]:
|
|
61
|
+
"""Deploy the tree card and/or published copy per the plan.
|
|
62
|
+
|
|
63
|
+
No restart side effects.
|
|
64
|
+
"""
|
|
65
|
+
notes: list[str] = []
|
|
66
|
+
frontend = settings.frontend
|
|
67
|
+
if frontend is None or not plan.frontend_changed:
|
|
68
|
+
return notes
|
|
69
|
+
|
|
70
|
+
if check:
|
|
71
|
+
if plan.frontend_tree_changed:
|
|
72
|
+
notes.append("Would deploy the card into the component tree.")
|
|
73
|
+
if plan.frontend_published_changed:
|
|
74
|
+
notes.append("Would publish the served card copy.")
|
|
75
|
+
return notes
|
|
76
|
+
|
|
77
|
+
if plan.frontend_tree_changed:
|
|
78
|
+
deploy_backend(ssh, settings, [frontend.source_rel])
|
|
79
|
+
notes.append("Deployed the card into the component tree.")
|
|
80
|
+
if plan.frontend_published_changed:
|
|
81
|
+
publish_frontend(ssh, settings)
|
|
82
|
+
notes.append(
|
|
83
|
+
"Published the served card copy; hard-refresh the browser "
|
|
84
|
+
"(Ctrl/Cmd+Shift+R) to pick it up."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return notes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Home Assistant adapter for ha_deploy_kit.
|
|
2
|
+
|
|
3
|
+
Importing this subpackage imports Home Assistant; the core never does.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ha_deploy_kit.ha.repairs_flow import async_create_restart_fix_flow
|
|
7
|
+
from ha_deploy_kit.ha.repairs_issue import (
|
|
8
|
+
async_clear_restart_issue,
|
|
9
|
+
async_raise_restart_issue,
|
|
10
|
+
)
|
|
11
|
+
from ha_deploy_kit.ha.service import async_register_restart_service
|
|
12
|
+
from ha_deploy_kit.ha.serving import register_card_serving
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"async_clear_restart_issue",
|
|
16
|
+
"async_create_restart_fix_flow",
|
|
17
|
+
"async_raise_restart_issue",
|
|
18
|
+
"async_register_restart_service",
|
|
19
|
+
"register_card_serving",
|
|
20
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Optional opt-in confirm-and-restart Repairs flow.
|
|
2
|
+
|
|
3
|
+
The restart happens ONLY on an explicit form submit. No executable restart lives
|
|
4
|
+
in the core engine; this flow is the single opt-in place a consumer may wire it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import voluptuous as vol
|
|
12
|
+
from homeassistant.components.repairs import RepairsFlow
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from homeassistant.core import HomeAssistant
|
|
16
|
+
from homeassistant.data_entry_flow import FlowResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _RestartRequiredFixFlow(RepairsFlow):
|
|
20
|
+
"""Shows a confirmation form, then restarts only when the user submits it."""
|
|
21
|
+
|
|
22
|
+
async def async_step_init(
|
|
23
|
+
self, user_input: dict[str, Any] | None = None
|
|
24
|
+
) -> FlowResult:
|
|
25
|
+
return await self.async_step_confirm_restart(user_input)
|
|
26
|
+
|
|
27
|
+
async def async_step_confirm_restart(
|
|
28
|
+
self, user_input: dict[str, Any] | None = None
|
|
29
|
+
) -> FlowResult:
|
|
30
|
+
if user_input is None:
|
|
31
|
+
return self.async_show_form(
|
|
32
|
+
step_id="confirm_restart", data_schema=vol.Schema({})
|
|
33
|
+
)
|
|
34
|
+
await self.hass.services.async_call("homeassistant", "restart", blocking=False)
|
|
35
|
+
return self.async_create_entry(title="", data={})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def async_create_restart_fix_flow(
|
|
39
|
+
hass: HomeAssistant,
|
|
40
|
+
issue_id: str,
|
|
41
|
+
data: dict[str, Any] | None = None,
|
|
42
|
+
*,
|
|
43
|
+
expected_issue_id: str = "restart_required",
|
|
44
|
+
) -> RepairsFlow | None:
|
|
45
|
+
"""Return the opt-in restart fix flow, or ``None`` for other issue ids."""
|
|
46
|
+
if issue_id != expected_issue_id:
|
|
47
|
+
return None
|
|
48
|
+
return _RestartRequiredFixFlow()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Repairs issue helpers for the restart-required notification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from homeassistant.helpers import issue_registry as ir
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from homeassistant.core import HomeAssistant
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def async_raise_restart_issue(
|
|
14
|
+
hass: HomeAssistant,
|
|
15
|
+
domain: str,
|
|
16
|
+
issue_id: str = "restart_required",
|
|
17
|
+
*,
|
|
18
|
+
translation_key: str = "restart_required",
|
|
19
|
+
is_fixable: bool = False,
|
|
20
|
+
is_persistent: bool = False,
|
|
21
|
+
learn_more_url: str | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Create a deduplicated restart issue scoped to ``domain``+``issue_id``.
|
|
24
|
+
|
|
25
|
+
Defaults to a non-persistent, notification-only warning. Keyed by
|
|
26
|
+
``(domain, issue_id)``, so it never overwrites another domain's issue.
|
|
27
|
+
"""
|
|
28
|
+
ir.async_create_issue(
|
|
29
|
+
hass,
|
|
30
|
+
domain,
|
|
31
|
+
issue_id,
|
|
32
|
+
is_fixable=is_fixable,
|
|
33
|
+
is_persistent=is_persistent,
|
|
34
|
+
severity=ir.IssueSeverity.WARNING,
|
|
35
|
+
translation_key=translation_key,
|
|
36
|
+
learn_more_url=learn_more_url,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def async_clear_restart_issue(
|
|
41
|
+
hass: HomeAssistant,
|
|
42
|
+
domain: str,
|
|
43
|
+
issue_id: str = "restart_required",
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Remove the restart issue for ``domain``+``issue_id`` if present."""
|
|
46
|
+
ir.async_delete_issue(hass, domain, issue_id)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Home Assistant service registration for the restart-required issue.
|
|
2
|
+
|
|
3
|
+
Imports Home Assistant; must be imported explicitly as ``ha_deploy_kit.ha``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import voluptuous as vol
|
|
11
|
+
|
|
12
|
+
from ha_deploy_kit.ha.repairs_issue import async_raise_restart_issue
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from homeassistant.core import HomeAssistant, ServiceCall
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def async_register_restart_service(
|
|
19
|
+
hass: HomeAssistant,
|
|
20
|
+
domain: str,
|
|
21
|
+
*,
|
|
22
|
+
service_name: str = "raise_restart_issue",
|
|
23
|
+
issue_id: str = "restart_required",
|
|
24
|
+
translation_key: str = "restart_required",
|
|
25
|
+
learn_more_url: str | None = None,
|
|
26
|
+
persistent: bool = False,
|
|
27
|
+
fixable: bool = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Idempotently register the service that raises the restart issue.
|
|
30
|
+
|
|
31
|
+
Registers with an empty ``vol.Schema({})`` (rejects any payload) and works
|
|
32
|
+
from global ``async_setup`` before any config entry loads.
|
|
33
|
+
|
|
34
|
+
Set ``fixable=True`` to raise a one-click (fixable) Repairs issue; Home
|
|
35
|
+
Assistant only runs a fix flow for a fixable issue, so pair this with a
|
|
36
|
+
wired ``async_create_restart_fix_flow``. The default stays notification-only.
|
|
37
|
+
"""
|
|
38
|
+
if hass.services.has_service(domain, service_name):
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
async def _handle(call: ServiceCall) -> None:
|
|
42
|
+
async_raise_restart_issue(
|
|
43
|
+
hass,
|
|
44
|
+
domain,
|
|
45
|
+
issue_id,
|
|
46
|
+
translation_key=translation_key,
|
|
47
|
+
is_fixable=fixable,
|
|
48
|
+
is_persistent=persistent,
|
|
49
|
+
learn_more_url=learn_more_url,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
hass.services.async_register(domain, service_name, _handle, schema=vol.Schema({}))
|