Skip to content
Merged
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
9 changes: 7 additions & 2 deletions installer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## v0.1.7 (2026-09-21)
## v0.1.8 (2026-09-21)

### Features

Expand All @@ -9,10 +9,15 @@
- **installer:** hide dashboard tabs for deselected optional services (#31) (59f2d40)
- **installer:** open the UI in an embedded system WebView (Part C2) (#35) (84bdc7a)
- **installer:** add package sources (file, repo build, GitHub) (#42) (f831eab)
- **services:** give every service its own version and changelog (c2e4e2c)
- **dashboard:** download the newest release bundle from the redeploy page (#44) (245b277)
- **services:** give every service its own version and changelog (#45) (5bc91b8)
- **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:** 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)
- **installer:** add texts for every fault code the bootstrap steps emit (23a64c3)

2 changes: 1 addition & 1 deletion installer/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.1.7
v0.1.8
8 changes: 7 additions & 1 deletion installer/internal/bundle/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,14 @@ func stagingTag(remoteDir string) string {
// archive, so it cannot by itself catch a bundle whose entire contents,
// script included, were forged together.
func Deploy(ctx context.Context, client *transport.Client, localArchivePath, remoteDir string) error {
return DeployProgress(ctx, client, localArchivePath, remoteDir, nil)
}

// DeployProgress is Deploy that reports the archive upload's progress
// (bytes sent, archive size); onProgress may be nil.
func DeployProgress(ctx context.Context, client *transport.Client, localArchivePath, remoteDir string, onProgress func(done, total int64)) error {
remoteArchive := fmt.Sprintf("/tmp/energy-node-installer-bundle-%s-%s.tar.gz", stagingTag(remoteDir), randomSuffix())
if err := client.UploadFile(localArchivePath, remoteArchive, 0o600); err != nil {
if err := client.UploadFileProgress(localArchivePath, remoteArchive, 0o600, onProgress); err != nil {
return fmt.Errorf("uploading bundle archive: %w", err)
}
defer client.RemoveRemote(remoteArchive)
Expand Down
19 changes: 18 additions & 1 deletion installer/internal/bundlesource/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
)

// lookPath is a seam for tests.
Expand Down Expand Up @@ -46,8 +47,24 @@ func MissingTools() []string {
return missing
}

// CheckRepo reports why path cannot be built from, or nil if it can.
// ExpandHome resolves a leading "~" or "~/" to the user's home directory and
// cleans the result. The operator types the path into the UI, where no shell
// expands it. Anything else (including "~user") is returned unchanged.
func ExpandHome(path string) string {
if path != "~" && !strings.HasPrefix(path, "~/") {
return path
}
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[1:])
}

// CheckRepo reports why path cannot be built from, or nil if it can. A
// leading "~" is expanded first.
func CheckRepo(path string) *Error {
path = ExpandHome(path)
if _, err := os.Stat(filepath.Join(path, filepath.FromSlash(makeBundleScript))); err != nil {
return &Error{Code: CodeRepoNotACheckout, Detail: path}
}
Expand Down
29 changes: 29 additions & 0 deletions installer/internal/bundlesource/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,32 @@ func TestCheckRepoReportsMissingTools(t *testing.T) {
t.Errorf("MissingTools = %v, want [go]", got)
}
}

func TestExpandHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home directory")
}
cases := map[string]string{
"~": home,
"~/dev/energy-node": filepath.Join(home, "dev", "energy-node"),
"~/dev/x/": filepath.Join(home, "dev", "x"),
"/abs/path": "/abs/path",
"rel/path": "rel/path",
"~other/x": "~other/x",
"": "",
}
for in, want := range cases {
if got := ExpandHome(in); got != want {
t.Errorf("ExpandHome(%q) = %q, want %q", in, got, want)
}
}
}

func TestCheckRepoExpandsAHomePrefix(t *testing.T) {
root := fakeCheckout(t)
t.Setenv("HOME", root)
if err := CheckRepo("~"); err != nil {
t.Fatalf("CheckRepo(~) = %+v, want nil", err)
}
}
1 change: 1 addition & 0 deletions installer/internal/bundlesource/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ func (r *Resolver) finish(req Request, dir, archive string, strict bool, cleanup
}

func (r *Resolver) buildFromRepo(ctx context.Context, req Request, log func(string)) (string, error) {
req.Path = ExpandHome(req.Path)
if err := CheckRepo(req.Path); err != nil {
return "", err
}
Expand Down
94 changes: 78 additions & 16 deletions installer/internal/faults/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,38 +21,100 @@ type Code string
// Die bekannten Codes. Quelle: die "Fehlercodes"-Zeilen der Plaene A-I/A-II
// und die fuenf Bundle-Codes aus Plan B-I.
const (
CodeBundleManifestMissing Code = "BUNDLE_MANIFEST_MISSING"
CodeBundleSignatureInvalid Code = "BUNDLE_SIGNATURE_INVALID"
CodeBundleHashMismatch Code = "BUNDLE_HASH_MISMATCH"
CodeArchMismatch Code = "ARCH_MISMATCH"
CodePythonABIMismatch Code = "PYTHON_ABI_MISMATCH"
CodeAptFailed Code = "APT_FAILED"
CodeMosquittoConfigInvalid Code = "MOSQUITTO_CONFIG_INVALID"
CodeUFWMissing Code = "UFW_MISSING"
CodePipExternallyManaged Code = "PIP_EXTERNALLY_MANAGED"
CodeWheelMissing Code = "WHEEL_MISSING"
CodeTailscaleFlagInvalid Code = "TAILSCALE_FLAG_INVALID"
CodeCaddyValidateFailed Code = "CADDY_VALIDATE_FAILED"
CodeUnitStartFailed Code = "UNIT_START_FAILED"
CodeConfigExists Code = "CONFIG_EXISTS"
CodeSudoRequired Code = "SUDO_REQUIRED"
CodeBundleManifestMissing Code = "BUNDLE_MANIFEST_MISSING"
CodeBundleSignatureInvalid Code = "BUNDLE_SIGNATURE_INVALID"
CodeBundleHashMismatch Code = "BUNDLE_HASH_MISMATCH"
CodeArchMismatch Code = "ARCH_MISMATCH"
CodePythonABIMismatch Code = "PYTHON_ABI_MISMATCH"
CodeAptFailed Code = "APT_FAILED"
CodeMosquittoArgsMissing Code = "MOSQUITTO_ARGS_MISSING"
CodeMosquittoConfigInvalid Code = "MOSQUITTO_CONFIG_INVALID"
CodeMQTTConfigUnreadable Code = "MQTT_CONFIG_UNREADABLE"
CodeUFWMissing Code = "UFW_MISSING"
CodePipExternallyManaged Code = "PIP_EXTERNALLY_MANAGED"
CodeWheelMissing Code = "WHEEL_MISSING"
CodeTailscaleFlagInvalid Code = "TAILSCALE_FLAG_INVALID"
CodeCaddyValidateFailed Code = "CADDY_VALIDATE_FAILED"
CodeUnitStartFailed Code = "UNIT_START_FAILED"
CodeConfigExists Code = "CONFIG_EXISTS"
CodeSudoRequired Code = "SUDO_REQUIRED"
CodeAptInstallFailed Code = "APT_INSTALL_FAILED"
CodeAptUpdateFailed Code = "APT_UPDATE_FAILED"
CodeBundleIncomplete Code = "BUNDLE_INCOMPLETE"
CodeCaddyBinaryMissing Code = "CADDY_BINARY_MISSING"
CodeCaddyConfigInvalid Code = "CADDY_CONFIG_INVALID"
CodeCaddyStartFailed Code = "CADDY_START_FAILED"
CodeConfigJsonMissing Code = "CONFIG_JSON_MISSING"
CodeConfigTemplateMissing Code = "CONFIG_TEMPLATE_MISSING"
CodeConfigWriteFailed Code = "CONFIG_WRITE_FAILED"
CodeDashboardBinaryMissing Code = "DASHBOARD_BINARY_MISSING"
CodeDashboardStartFailed Code = "DASHBOARD_START_FAILED"
CodeManifestsMissing Code = "MANIFESTS_MISSING"
CodeManifestMissing Code = "MANIFEST_MISSING"
CodeManifestParseFailed Code = "MANIFEST_PARSE_FAILED"
CodeMosquittoConfForeign Code = "MOSQUITTO_CONF_FOREIGN"
CodeMosquittoPasswdFailed Code = "MOSQUITTO_PASSWD_FAILED"
CodePipInstallFailed Code = "PIP_INSTALL_FAILED"
CodeSecretFileMissing Code = "SECRET_FILE_MISSING"
CodeSelectionUnreadable Code = "SELECTION_UNREADABLE"
CodeServiceSourceMissing Code = "SERVICE_SOURCE_MISSING"
CodeServiceStartFailed Code = "SERVICE_START_FAILED"
CodeServiceUnitFailed Code = "SERVICE_UNIT_FAILED"
CodeSudoersInvalid Code = "SUDOERS_INVALID"
CodeTailscaleInstallFailed Code = "TAILSCALE_INSTALL_FAILED"
CodeTailscaleTarballMissing Code = "TAILSCALE_TARBALL_MISSING"
CodeTargetInvalid Code = "TARGET_INVALID"
CodeUfwFailed Code = "UFW_FAILED"
CodeUpdaterPathStartFailed Code = "UPDATER_PATH_START_FAILED"
CodeWheelsMissing Code = "WHEELS_MISSING"
)

var allCodes = []Code{
CodeArchMismatch,
CodeAptFailed,
CodeAptInstallFailed,
CodeAptUpdateFailed,
CodeArchMismatch,
CodeBundleHashMismatch,
CodeBundleIncomplete,
CodeBundleManifestMissing,
CodeBundleSignatureInvalid,
CodeCaddyBinaryMissing,
CodeCaddyConfigInvalid,
CodeCaddyStartFailed,
CodeCaddyValidateFailed,
CodeConfigExists,
CodeConfigJsonMissing,
CodeConfigTemplateMissing,
CodeConfigWriteFailed,
CodeDashboardBinaryMissing,
CodeDashboardStartFailed,
CodeManifestsMissing,
CodeManifestMissing,
CodeManifestParseFailed,
CodeMosquittoArgsMissing,
CodeMosquittoConfigInvalid,
CodeMosquittoConfForeign,
CodeMosquittoPasswdFailed,
CodeMQTTConfigUnreadable,
CodePipExternallyManaged,
CodePipInstallFailed,
CodePythonABIMismatch,
CodeSecretFileMissing,
CodeSelectionUnreadable,
CodeServiceSourceMissing,
CodeServiceStartFailed,
CodeServiceUnitFailed,
CodeSudoersInvalid,
CodeSudoRequired,
CodeTailscaleFlagInvalid,
CodeTailscaleInstallFailed,
CodeTailscaleTarballMissing,
CodeTargetInvalid,
CodeUfwFailed,
CodeUFWMissing,
CodeUnitStartFailed,
CodeUpdaterPathStartFailed,
CodeWheelsMissing,
CodeWheelMissing,
}

Expand Down
33 changes: 32 additions & 1 deletion installer/internal/faults/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,51 @@ func TestEveryCodeHasBothFields(t *testing.T) {
// step actually failed on a real node.
func TestCatalogCoversTheStableCodeInventory(t *testing.T) {
want := []string{
"ARCH_MISMATCH",
"APT_FAILED",
"APT_INSTALL_FAILED",
"APT_UPDATE_FAILED",
"ARCH_MISMATCH",
"BUNDLE_HASH_MISMATCH",
"BUNDLE_INCOMPLETE",
"BUNDLE_MANIFEST_MISSING",
"BUNDLE_SIGNATURE_INVALID",
"CADDY_BINARY_MISSING",
"CADDY_CONFIG_INVALID",
"CADDY_START_FAILED",
"CADDY_VALIDATE_FAILED",
"CONFIG_EXISTS",
"CONFIG_JSON_MISSING",
"CONFIG_TEMPLATE_MISSING",
"CONFIG_WRITE_FAILED",
"DASHBOARD_BINARY_MISSING",
"DASHBOARD_START_FAILED",
"MANIFESTS_MISSING",
"MANIFEST_MISSING",
"MANIFEST_PARSE_FAILED",
"MOSQUITTO_ARGS_MISSING",
"MOSQUITTO_CONFIG_INVALID",
"MOSQUITTO_CONF_FOREIGN",
"MOSQUITTO_PASSWD_FAILED",
"MQTT_CONFIG_UNREADABLE",
"PIP_EXTERNALLY_MANAGED",
"PIP_INSTALL_FAILED",
"PYTHON_ABI_MISMATCH",
"SECRET_FILE_MISSING",
"SELECTION_UNREADABLE",
"SERVICE_SOURCE_MISSING",
"SERVICE_START_FAILED",
"SERVICE_UNIT_FAILED",
"SUDOERS_INVALID",
"SUDO_REQUIRED",
"TAILSCALE_FLAG_INVALID",
"TAILSCALE_INSTALL_FAILED",
"TAILSCALE_TARBALL_MISSING",
"TARGET_INVALID",
"UFW_FAILED",
"UFW_MISSING",
"UNIT_START_FAILED",
"UPDATER_PATH_START_FAILED",
"WHEELS_MISSING",
"WHEEL_MISSING",
}
for _, code := range want {
Expand Down
32 changes: 28 additions & 4 deletions installer/internal/host/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"

"github.com/Developer-Simon/energy-node-installer/internal/bundle"
Expand All @@ -20,12 +21,35 @@ import (
// Die Nahtstellen zum Node; Tests ersetzen sie, weil sie SSH brauchen.
var (
detectMachine = defaultDetectMachine
stageBundle = func(ctx context.Context, c *transport.Client, archive, remoteDir string) error {
return bundle.Deploy(ctx, c, archive, remoteDir)
stageBundle = func(ctx context.Context, c *transport.Client, archive, remoteDir string, onProgress func(done, total int64)) error {
return bundle.DeployProgress(ctx, c, archive, remoteDir, onProgress)
}
verifyStaged = defaultVerifyStaged
)

// uploadProgress turns byte counts into one note per 5 % step, so the UI
// shows movement without a message for every 32 KiB packet.
func uploadProgress(notef func(string, map[string]string)) func(done, total int64) {
lastStep := 0
return func(done, total int64) {
if total <= 0 {
return
}
step := int(done * 20 / total) // 0..20, one per 5 %
if step <= lastStep {
return
}
lastStep = step
notef("package.log.upload_progress", map[string]string{
"percent": strconv.Itoa(step * 5),
"done": megabytes(done),
"total": megabytes(total),
})
}
}

func megabytes(n int64) string { return fmt.Sprintf("%.1f", float64(n)/(1<<20)) }

func defaultDetectMachine(ctx context.Context, c *transport.Client) (string, error) {
var stdout, stderr strings.Builder
if err := c.Run(ctx, "uname -m", &stdout, &stderr); err != nil {
Expand Down Expand Up @@ -78,7 +102,7 @@ func (h *Host) SelectPackage(ctx context.Context, sel hostapi.PackageSelection)
if err := bundlesource.CheckRepo(sel.Path); err != nil {
return &hostapi.Error{Code: err.Code, Detail: err.Detail, Status: http.StatusBadRequest}
}
req.Path = sel.Path
req.Path = bundlesource.ExpandHome(sel.Path)
default:
return &hostapi.Error{Code: "BAD_REQUEST", Detail: "unbekannte Paketquelle: " + sel.Kind, Status: http.StatusBadRequest}
}
Expand Down Expand Up @@ -223,7 +247,7 @@ func (h *Host) doPrepare(ctx context.Context, client *transport.Client, logf fun
return &hostapi.Error{Code: "PACKAGE_STAGE_FAILED", Detail: err.Error()}
}
logf("Paket auf das Geraet uebertragen")
if err := stageBundle(ctx, client, archive, h.cfg.RemoteBundleDir); err != nil {
if err := stageBundle(ctx, client, archive, h.cfg.RemoteBundleDir, uploadProgress(notef)); err != nil {
return &hostapi.Error{Code: "PACKAGE_STAGE_FAILED", Detail: err.Error()}
}
logf("Paket auf dem Geraet pruefen")
Expand Down
Loading
Loading