Skip to content
Open
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
8 changes: 7 additions & 1 deletion dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Changelog

## v0.7.6 (2026-09-21)
## v0.7.7 (2026-09-22)

### Features

- **installer:** add package sources (file, repo build, GitHub) (#42) (f831eab)
- **dashboard:** download the newest release bundle from the redeploy page (#44) (245b277)
- **installer:** carry a restart-all request from the UI to the service steps (bad757a)
- **installer:** show per service which units restart in the preview (e2464cb)
- **updater:** take the target from the root-owned target.json for user-independent bundles (e3813fc)
- **dashboard:** add a GitHub release client that finds and downloads the node bundle (0493264)
- **dashboard:** extract, validate and atomically install a downloaded bundle (e0896ae)
Expand All @@ -24,6 +26,10 @@
- **dashboard:** hand the redeploy page the session's CSRF token (7bd8f3a)
- **dashboard:** implement the new hostapi.Sink.Message method (8ae6af3)

### Documentation

- describe which service units an update restarts (08ae4b9)

### Tests

- **dashboard:** show the package download in the local smoke test (f2c2f2c)
Expand Down
2 changes: 1 addition & 1 deletion dashboard/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.7.6
v0.7.7
15 changes: 14 additions & 1 deletion dashboard/energy-node-updater.sh
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,18 @@ if [[ -z "${job_steps}" ]]; then
fi
mapfile -t step_ids <<< "${job_steps}"

# restart_all: job.json ist dashboard-beschreibbar und ungesigniert, darf den
# Neustart also nur ausweiten, nie sonst etwas steuern - deshalb nur "true"
# akzeptiert, alles andere (fehlend, false, kaputt) bleibt "".
restart_all=""
if [[ "$(python3 - "${CURRENT}" <<'PY'
import json, sys
print("all" if json.load(open(sys.argv[1], encoding="utf-8")).get("restart_all") is True else "")
PY
)" == all ]]; then
restart_all=all
fi

# step_known haelt eine Kennung aus job.json gegen das gepruefte Manifest.
# Ohne diese Pruefung baute eine Kennung wie "../../etc/cron.d/x" den
# Glob unten aus dem Bootstrap-Verzeichnis heraus, und root fuehrte aus,
Expand Down Expand Up @@ -322,7 +334,8 @@ for id in "${step_ids[@]}"; do
# bereits als root, und ein zusaetzliches sudo haenge den Lauf an eine
# gesunde sudoers-Konfiguration und strippte Umgebungsvariablen, auf die
# einzelne Schritte bauen (DEBIAN_FRONTEND in 10-apt.sh).
done < <(EN_STATE_DIR="${STATE_DIR}" EN_SELECTION="${STATE_DIR}/selection.json" \
done < <(env ${restart_all:+EN_RESTART=all} \
EN_STATE_DIR="${STATE_DIR}" EN_SELECTION="${STATE_DIR}/selection.json" \
EN_BUNDLE_DIR="${BUNDLE}" EN_BUNDLE_VERSION="${bundle_version}" \
EN_TARGET_USER="${target_user}" EN_TARGET_BASE="${target_base}" \
EN_SUDO="" \
Expand Down
33 changes: 28 additions & 5 deletions dashboard/internal/updaterhost/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,18 @@ type candidateManifest struct {
Steps []struct {
ID string `json:"id"`
Optional bool `json:"optional"`
Dir string `json:"dir"`
Version string `json:"version"`
} `json:"steps"`
}

type installedManifest struct {
Version string `json:"version"`
Version string `json:"version"`
Components map[string]string `json:"components"`
Steps []struct {
ID string `json:"id"`
Version string `json:"version"`
} `json:"steps"`
}

type selectionFile struct {
Expand Down Expand Up @@ -163,9 +170,12 @@ func (h *Host) Plan(context.Context) (*hostapi.PlanView, error) {
if err != nil {
return nil, err
}
var installed installedManifest
var installed *installedManifest
if raw, err := os.ReadFile(h.cfg.InstalledManifestPath); err == nil {
_ = json.Unmarshal(raw, &installed)
var doc installedManifest
if json.Unmarshal(raw, &doc) == nil {
installed = &doc
}
}

view := &hostapi.PlanView{BundleVersion: candidate.Version, Components: map[string]hostapi.ComponentDelta{}}
Expand All @@ -178,7 +188,20 @@ func (h *Host) Plan(context.Context) (*hostapi.PlanView, error) {
if s.Optional && !sel.Steps[s.ID] {
state = "deselected"
}
view.Steps = append(view.Steps, hostapi.PlanStep{ID: s.ID, Optional: s.Optional, Selected: selected, State: state})
ps := hostapi.PlanStep{ID: s.ID, Optional: s.Optional, Selected: selected, State: state}
if s.Dir != "" {
if installed != nil {
for _, is := range installed.Steps {
if is.ID == s.ID {
ps.From = is.Version
}
}
}
if state == "pending" {
ps.Restart = restartReason(installed, candidate, s.ID, false)
}
}
view.Steps = append(view.Steps, ps)
}
return view, nil
}
Expand Down Expand Up @@ -222,7 +245,7 @@ func (h *Host) Run(ctx context.Context, req hostapi.RunRequest, sink hostapi.Sin
// manifest instead.
job := updaterjob.Job{
BundleVersion: candidate.Version, Mode: string(req.Mode), Only: req.Only,
Steps: stepIDs,
Steps: stepIDs, RestartAll: req.RestartAll,
}
if err := updaterjob.Stage(h.cfg.JobDir, job, h.cfg.CandidateBundleDir); err != nil {
return &hostapi.Error{Code: "JOB_STAGING_FAILED", Detail: err.Error()}
Expand Down
66 changes: 66 additions & 0 deletions dashboard/internal/updaterhost/restart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package updaterhost

// libraryUsers maps a shared library to the service directories that import
// it; nil means every service. Keep in step with LIBRARY_USERS in
// scripts/bootstrap/lib/restart_rule.py (restart_test.go checks the rule, and
// test_restart_rule.py checks that table against the services' imports).
var libraryUsers = map[string][]string{
"energy_node_common": nil,
"battery_soc_core": {"battery_soc"},
}

// restartReason is the Go copy of restart_rule.restart_reason: "" (no
// restart), "all", "first", "unknown", "version" or "library".
func restartReason(installed *installedManifest, candidate *candidateManifest, stepID string, restartAll bool) string {
if restartAll {
return "all"
}
if candidate == nil {
return "unknown"
}
if installed == nil {
return "first"
}
var dir, newVersion string
found := false
for _, s := range candidate.Steps {
if s.ID == stepID {
dir, newVersion, found = s.Dir, s.Version, true
break
}
}
if !found {
return ""
}
oldVersion, seen := "", false
for _, s := range installed.Steps {
if s.ID == stepID {
oldVersion, seen = s.Version, true
break
}
}
if !seen {
return "first"
}
if oldVersion == "" || newVersion == "" {
return "unknown"
}
if oldVersion != newVersion {
return "version"
}
for name, users := range libraryUsers {
version := candidate.Components[name]
if version == "" || installed.Components[name] == version {
continue
}
if users == nil {
return "library"
}
for _, u := range users {
if u == dir {
return "library"
}
}
}
return ""
}
32 changes: 32 additions & 0 deletions dashboard/internal/updaterhost/restart_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package updaterhost

import (
"encoding/json"
"os"
"testing"
)

// The bash/Python rule (scripts/bootstrap/lib/restart_rule.py) and this Go
// copy must agree; both read the same table.
func TestRestartReasonMatchesTheSharedTable(t *testing.T) {
raw, err := os.ReadFile("../../../scripts/bootstrap/testdata/restart_cases.json")
if err != nil {
t.Fatal(err)
}
var cases []struct {
Name string `json:"name"`
Candidate *candidateManifest `json:"candidate"`
Installed *installedManifest `json:"installed"`
StepID string `json:"step_id"`
RestartAll bool `json:"restart_all"`
Want string `json:"want"`
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
for _, c := range cases {
if got := restartReason(c.Installed, c.Candidate, c.StepID, c.RestartAll); got != c.Want {
t.Errorf("%s: got %q, want %q", c.Name, got, c.Want)
}
}
}
1 change: 1 addition & 0 deletions dashboard/internal/updaterjob/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Job struct {
Mode string `json:"mode"`
Only string `json:"only,omitempty"`
Steps []string `json:"steps"`
RestartAll bool `json:"restart_all,omitempty"`
}

// Status is the updater's final report, written once to status.json. A
Expand Down
32 changes: 32 additions & 0 deletions dashboard/internal/updaterjob/job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,38 @@ func TestStageWritesJobJSONAndPendingTrigger(t *testing.T) {
}
}

func TestStageWritesRestartAllOnlyWhenSet(t *testing.T) {
dir := t.TempDir()
bundle := t.TempDir()
if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), []byte(`{"version":"1.5.0"}`), 0o644); err != nil {
t.Fatal(err)
}
job := updaterjob.Job{BundleVersion: "1.5.0", Mode: "redeploy", Steps: []string{"50"}, RestartAll: true}
if err := updaterjob.Stage(dir, job, bundle); err != nil {
t.Fatalf("Stage: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "pending.json"))
if err != nil {
t.Fatal(err)
}
if !contains(string(raw), `"restart_all":true`) {
t.Fatalf("restart_all missing from pending.json: %s", raw)
}

dir2 := t.TempDir()
job2 := updaterjob.Job{BundleVersion: "1.5.0", Mode: "redeploy", Steps: []string{"50"}}
if err := updaterjob.Stage(dir2, job2, bundle); err != nil {
t.Fatalf("Stage: %v", err)
}
raw2, err := os.ReadFile(filepath.Join(dir2, "pending.json"))
if err != nil {
t.Fatal(err)
}
if contains(string(raw2), "restart_all") {
t.Fatalf("restart_all present without being set: %s", raw2)
}
}

func TestStageRefusesAConcurrentJob(t *testing.T) {
dir := t.TempDir()
bundle := t.TempDir()
Expand Down
23 changes: 20 additions & 3 deletions dashboard/test/smoke/run-local-dashboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ manifest = {
{"id": "30", "optional": False}, {"id": "40", "optional": True},
{"id": "50", "optional": False}, {"id": "60", "optional": False},
{"id": "65", "optional": False}, {"id": "70", "optional": True},
# Zwei Dienstschritte, damit die Vorschau etwas zum Neustart-Vergleich
# hat: battery_soc geaendert (Neustart), apsystems unveraendert.
{"id": "81", "optional": True, "dir": "apsystems_ez1", "unit": "apsystems-ez1.service", "version": "v0.4.0"},
{"id": "82", "optional": True, "dir": "battery_soc", "unit": "battery-soc.service", "version": "v0.5.0"},
],
}
files = {
Expand All @@ -336,8 +340,15 @@ with tarfile.open(archive, "w:gz") as tar:
info.size = len(body)
info.mode = 0o755 if name.endswith(".sh") else 0o644
tar.addfile(info, io.BytesIO(body))
json.dump({"steps": {"40": True, "70": False}}, open(os.path.join(state, "selection.json"), "w"))
json.dump({"version": "v0.7.0"}, open(os.path.join(state, "installed-manifest.json"), "w"))
json.dump({"steps": {"40": True, "70": False, "81": True, "82": True}}, open(os.path.join(state, "selection.json"), "w"))
json.dump({
"version": "v0.7.0",
"components": {},
"steps": [
{"id": "82", "version": "v0.4.0"},
{"id": "81", "version": "v0.4.0"},
],
}, open(os.path.join(state, "installed-manifest.json"), "w"))
PY
FAKE_GITHUB_ARGS+=("$WORK/package/$PACKAGE_ASSET")
fi
Expand Down Expand Up @@ -699,11 +710,17 @@ if [[ $SIMULATE_PACKAGE -eq 1 ]]; then
echo " FEHL Bundle wurde nicht heruntergeladen (siehe $WORK/dashboard.log)"; FAILED=1
fi
check "Vorschau nennt Version und alle Schritte des heruntergeladenen Bundles" \
"data['bundle_version'] == 'v9.9.9' and [s['id'] for s in data['steps']] == ['10','20','30','40','50','60','65','70']" \
"data['bundle_version'] == 'v9.9.9' and [s['id'] for s in data['steps']] == ['10','20','30','40','50','60','65','70','81','82']" \
"$BASE/redeploy/api/plan"
check "Auswahl des Nodes gilt (optionaler Schritt 70 abgewaehlt)" \
"any(s['id'] == '70' and s['state'] == 'deselected' for s in data['steps'])" \
"$BASE/redeploy/api/plan"
check "geaenderter Dienst (battery_soc) steht mit restart=version in der Vorschau" \
"next(s for s in data['steps'] if s['id'] == '82')['restart'] == 'version'" \
"$BASE/redeploy/api/plan"
check "unveraenderter Dienst (apsystems) hat keinen Neustartgrund" \
"not next(s for s in data['steps'] if s['id'] == '81').get('restart')" \
"$BASE/redeploy/api/plan"
check "Die Oberflaeche kennt jetzt das bereitliegende Paket" \
"data['auto_prepare'] is True and data['bundle_version'] == 'v9.9.9'" \
"$BASE/redeploy/api/bootstrap"
Expand Down
15 changes: 10 additions & 5 deletions docs/installer.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,16 @@ file.

Choose **Update** and connect. The preview compares each component's version
against the stamp on the Pi and lists what changes, which services restart, and
which steps are skipped because their work is done. The scope card shows the
services as they were last selected; a service that is new in the package stays
off until you tick it (**Change services**). **Update** runs exactly the steps
that are pending — the same code path as a fresh install, so there is no
separate "partial" update to go wrong.
which steps are skipped because their work is done. By default, only a service
whose own version changed restarts; a change to `energy_node_common` restarts
every service, and a change to `battery_soc_core` restarts `battery_soc` alone.
Tick **Restart all services** to restart every service unit regardless. A unit
that is not currently running always starts, whatever the rule says, and a node
whose last run predates per-service versions restarts every service once. The
scope card shows the services as they were last selected; a service that is new
in the package stays off until you tick it (**Change services**). **Update**
runs exactly the steps that are pending — the same code path as a fresh
install, so there is no separate "partial" update to go wrong.

The dashboard serves the same page under `/redeploy/`, behind its login, and
runs the same steps on the node itself, without this program and without SSH.
Expand Down
5 changes: 4 additions & 1 deletion installer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## v0.1.8 (2026-09-21)
## v0.1.9 (2026-09-22)

### Features

Expand All @@ -11,12 +11,15 @@
- **installer:** add package sources (file, repo build, GitHub) (#42) (f831eab)
- **dashboard:** download the newest release bundle from the redeploy page (#44) (245b277)
- **services:** give every service its own version and changelog (#45) (5bc91b8)
- **installer:** carry a restart-all request from the UI to the service steps (bad757a)
- **installer:** show per service which units restart in the preview (e2464cb)
- **installer:** report the bundle upload progress and write concurrently (19df0c0)

### Fixes

- **installer:** restart service units on update and record the installed manifest (#43) (0f2aec2)
- **installer:** make redeploy, repair and repo-built bundles install cleanly (#46) (bf1fe10)
- **installer:** localize the package-preparation log lines (c67b49d)
- **installer:** expand ~ in the repo package path (873bdda)
- **installer:** replace remote files the SSH user cannot open for writing (eca0198)
- **installer:** give step 20 its MQTT arguments from the node on redeploy and repair (bb4cd40)
Expand Down
2 changes: 1 addition & 1 deletion installer/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.1.8
v0.1.9
Loading
Loading