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
5 changes: 5 additions & 0 deletions .changeset/driver-versions-and-override-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Pick which driver version runs, and see when your own copy has fallen behind. The signed channel keeps every version it has ever published, but rollback only stepped back one, so a specific older version was out of reach from the UI. Each managed driver now has a Versions list showing what is downloaded, what the channel offers, and which one is live. A driver you supply yourself is now also told when the channel has something newer — your copy keeps running until you decide otherwise.
29 changes: 24 additions & 5 deletions go/internal/driverrepo/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,10 @@ func (m *Manager) EnrichCatalog(entries []drivers.CatalogEntry) []drivers.Catalo
activeByLogical[in.LogicalPath] = in
}
for i := range entries {
if entries[i].Source == "local" {
continue
}
// A local file is an operator's own copy and is not a managed install,
// so none of the install metadata below applies to it. It still needs
// the upstream comparison: an override shadows the channel silently,
// and without this the operator is never told a newer version exists.
if in, ok := activeByLogical[entries[i].Path]; ok && entries[i].Source == "managed" {
entries[i].InstalledVersion = in.Version
entries[i].RepositoryID = in.RepoID
Expand All @@ -434,20 +435,31 @@ func (m *Manager) EnrichCatalog(entries []drivers.CatalogEntry) []drivers.Catalo
break
}
}
local := entries[i].Source == "local"
for _, candidate := range candidates {
if candidate.Driver.ID != entries[i].ID {
continue
}
if entries[i].UpstreamVersion == "" || compareSemver(candidate.Driver.Version, entries[i].UpstreamVersion) > 0 {
entries[i].UpstreamVersion = candidate.Driver.Version
entries[i].RepositoryID = candidate.RepositoryID
// A local file came from the operator, not from a repository.
// Naming one here would read as provenance it does not have.
if !local {
entries[i].RepositoryID = candidate.RepositoryID
}
}
}
base := entries[i].Version
if entries[i].InstalledVersion != "" {
base = entries[i].InstalledVersion
}
entries[i].UpdateAvailable = entries[i].UpstreamVersion != "" && compareSemver(entries[i].UpstreamVersion, base) > 0
// Only claim an update when both sides are real versions that can be
// ordered. A local driver often carries no version, or something like
// "local", and comparing that as a string would announce an update
// on nothing better than alphabetical luck.
entries[i].UpdateAvailable = entries[i].UpstreamVersion != "" &&
isSemver(entries[i].UpstreamVersion) && isSemver(base) &&
compareSemver(entries[i].UpstreamVersion, base) > 0
}
return entries
}
Expand Down Expand Up @@ -1073,6 +1085,13 @@ func decodeBase64(value string) ([]byte, error) {

var semverRE = regexp.MustCompile(`^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)

// isSemver reports whether a version can be ordered at all. compareSemver
// falls back to comparing strings, which is fine for sorting and wrong for
// deciding whether a newer release exists.
func isSemver(value string) bool {
return semverRE.MatchString(value)
}

func compareSemver(a, b string) int {
type version struct {
core [3]int
Expand Down
33 changes: 33 additions & 0 deletions go/internal/driverrepo/sourceful_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,39 @@ func TestSourcefulIndexPackageInstallAndOfflineCache(t *testing.T) {
if local.RepositoryID != "" || local.PackageID != "" || local.ArtifactSHA256 != "" || local.UpdateAvailable {
t.Fatalf("local catalog claimed managed provenance = %+v", local)
}
// An override still has to be told what the channel offers. Running your
// own copy shadows the channel, so without this the operator is never told
// a newer version exists -- and the version they keep is their choice to
// make, not something to be silently replaced.
if local.UpstreamVersion != "1.1.1" {
t.Fatalf("local override was not told the channel has 1.1.1: %+v", local)
}

// An override carrying a real, older version is a genuine update, and the
// operator should see it. Resolution is unchanged: the local file keeps
// winning until they act on it.
older := manager.EnrichCatalog([]drivers.CatalogEntry{{
Path: "drivers/sdm630.lua", ID: "sdm630", Version: "1.0.0", Source: "local",
}})[0]
if !older.UpdateAvailable || older.UpstreamVersion != "1.1.1" {
t.Fatalf("older local override should offer an update = %+v", older)
}
if older.RepositoryID != "" || older.PackageID != "" {
t.Fatalf("an update offer is not provenance = %+v", older)
}

// A version that cannot be ordered must not produce a claim either way.
// compareSemver falls back to comparing strings, which would announce an
// update on alphabetical luck.
unversioned := manager.EnrichCatalog([]drivers.CatalogEntry{{
Path: "drivers/sdm630.lua", ID: "sdm630", Source: "local",
}})[0]
if unversioned.UpdateAvailable {
t.Fatalf("an unversioned override cannot be compared = %+v", unversioned)
}
if unversioned.UpstreamVersion != "1.1.1" {
t.Fatalf("it should still be told what exists = %+v", unversioned)
}
policy, err := manager.RuntimePolicy(config.Driver{
Name: "sdm630", Lua: filepath.Join(manager.ActiveDir(), "sdm630.lua"),
})
Expand Down
91 changes: 91 additions & 0 deletions web/driver-versions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Choosing which driver version runs, and never being surprised by it.
//
// Two properties matter here and they pull in opposite directions:
//
// 1. An operator running their own copy must still be told when the channel
// has something newer. An override shadows the channel silently, so
// without this they never find out.
//
// 2. Being told must not mean being changed. Their copy keeps running until
// they act. The channel keeps every version it has ever signed, and
// reaching a specific one is their decision.

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(here, "settings/tabs/devices.js"), "utf8");

// Just renderVersionPicker, so assertions about it cannot be satisfied or
// broken by unrelated code further down the file.
function renderVersionPickerBody() {
const start = source.indexOf("function renderVersionPicker");
assert.notEqual(start, -1, "renderVersionPicker must exist");
const next = source.indexOf("\n function ", start + 1);
return source.slice(start, next === -1 ? undefined : next);
}

test("a local override is told what the channel has", () => {
assert.match(source, /source === "local" && entry\.upstream_version/,
"an override shadows the channel, so it must surface the newer version " +
"rather than leaving the operator to find out some other way");
assert.match(source, /channel has v/,
"the version itself has to be on screen, not just the fact of an update");
});

test("the override is not offered a button that would do nothing", () => {
// Installing a channel version while a local file is present changes
// nothing: the local file still wins. A button implying otherwise would be
// a lie the operator only discovers by debugging.
const localBranch = source.slice(
source.indexOf('source === "local" && entry.upstream_version'),
source.indexOf('if (source === "managed")'));
assert.ok(!/drv-module-update/.test(localBranch),
"the update button belongs to managed drivers, not overrides");
assert.match(localBranch, /your own copy is used while it is present/,
"say why nothing is being offered");
});

test("the version picker is offered for managed drivers only", () => {
assert.match(source, /source !== "local"[\s\S]{0,200}drv-module-versions/,
"an override has no channel versions to switch between");
});

test("the picker distinguishes downloaded versions from ones still in the channel", () => {
const render = renderVersionPickerBody();
assert.match(render, /body && body\.installed/);
assert.match(render, /body && body\.available/);

// Activating something already on disk needs no network; fetching does.
// The labels have to tell those apart or the operator cannot tell why one
// is instant and the other is not.
assert.match(render, /row\.local \? "Activate" : "Fetch and activate"/);
assert.match(render, /row\.local[\s\S]{0,120}"\/activate"[\s\S]{0,40}"\/install"/,
"an installed version activates; one that is not installed installs");
});

test("the running version is marked and cannot be re-activated", () => {
const render = renderVersionPickerBody();
assert.match(render, /row\.active \? " · running" : ""/,
"an operator must be able to see which one is live");
assert.match(render, /if \(!row\.active\) \{/,
"no button on the version that is already running");
});

test("the picker builds DOM instead of assembling HTML", () => {
const render = renderVersionPickerBody();
assert.ok(!/innerHTML/.test(render),
"version strings come from a signed manifest, but the manifest is still " +
"remote input and must not be able to inject markup");
assert.match(render, /createElement\("button"\)/);
assert.match(render, /textContent = "v" \+ row\.version/);
});

test("an empty history says so rather than rendering nothing", () => {
const render = renderVersionPickerBody();
assert.match(render, /No versions found for this driver/,
"a silently empty panel looks like a broken request");
});
119 changes: 119 additions & 0 deletions web/settings/tabs/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,90 @@
return true;
}

// Every version the channel has signed for this driver, and which one is
// running. Rollback steps back exactly one; this is how an operator reaches
// a specific older version, which is the whole reason the channel keeps a
// history in the first place.
function renderVersionPicker(panel, driverID, body) {
var installed = (body && body.installed) || [];
var available = (body && body.available) || [];
panel.textContent = "";

if (installed.length === 0 && available.length === 0) {
panel.textContent = "No versions found for this driver.";
return;
}

// Downloaded already: activating one is instant and needs no network.
var installedVersions = {};
installed.forEach(function (v) { if (v && v.version) installedVersions[v.version] = true; });

var rows = [];
installed.forEach(function (v) {
rows.push({version: v.version, sha256: v.sha256, active: !!v.active, local: true});
});
available.forEach(function (v) {
var version = typeof v === "string" ? v : (v && v.version);
if (!version || installedVersions[version]) return;
rows.push({version: version, active: false, local: false});
Comment on lines +67 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Read the version from the nested candidate

When a version exists only in the signed repository, the versions endpoint returns each available item as a VersionCandidate shaped like {repository_id, driver: {version, ...}}, not as an object with a top-level version (the existing update-badge.js consumer follows this shape). Consequently v.version is undefined and every channel-only candidate is skipped, leaving bundled drivers with a blank panel and preventing the advertised selection of older remote versions.

Useful? React with 👍 / 👎.

});

rows.forEach(function (row) {
var line = document.createElement("div");
line.style.display = "flex";
line.style.alignItems = "center";
line.style.gap = "8px";
line.style.marginTop = "4px";

var label = document.createElement("span");
label.className = "creds-badge";
label.textContent = "v" + row.version + (row.active ? " · running" : "");
line.appendChild(label);

if (!row.active) {
var action = document.createElement("button");
action.type = "button";
action.className = "btn-add";
// Already downloaded, so this only switches which one runs. A version
// that is not downloaded has to be fetched from the channel first.
action.textContent = row.local ? "Activate" : "Fetch and activate";
action.addEventListener("click", function () {
action.disabled = true;
var status = line.querySelector(".drv-version-status");
var endpoint = row.local ? "/activate" : "/install";
var payload = row.local
? {version: row.version, sha256: row.sha256 || ""}
: {version: row.version};
Comment on lines +96 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Include the repository when fetching a version

When the operator selects a version that is not downloaded, this payload contains only version, but handleDeviceRepositoryInstall rejects requests unless either repository_id or channel is present. Thus every “Fetch and activate” request will return HTTP 400 even after the available candidates are rendered; preserve the candidate's repository_id in the row and send it with the install request.

Useful? React with 👍 / 👎.

if (status) status.textContent = row.local ? "Activating…" : "Fetching…";
apiFetch("/api/device_repository/drivers/" + encodeURIComponent(driverID) + endpoint, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().then(function (b) {
if (!r.ok) throw new Error(b.error || "could not switch version");
return b;
});
}).then(function () {
if (status) status.textContent = "v" + row.version + " is now running.";
}).catch(function (err) {
if (status) status.textContent = err.message;
action.disabled = false;
});
});
line.appendChild(action);
}

var status = document.createElement("span");
status.className = "drv-version-status";
status.style.color = "var(--text-dim)";
status.style.fontSize = "0.75rem";
line.appendChild(status);

panel.appendChild(line);
});
}

function configProfilesFor(entry) {
if (!entry || entry.id !== "goodwe" || !versionAtLeast(entry.version, "1.0.2")) return [];
return DRIVER_CONFIG_PROFILES.goodwe;
Expand Down Expand Up @@ -448,11 +532,27 @@
'" data-repository-id="' + escHtml(entry.repository_id) + '" data-version="' + escHtml(entry.upstream_version) + '">Update to v' +
escHtml(entry.upstream_version) + '</button>';
}
// An override shadows the channel, so installing a newer version
// would not change what runs. Say that, rather than offering a
// button that appears to do nothing. Their copy stays active until
// they remove it themselves.
if (source === "local" && entry.upstream_version) {
html += ' <span class="creds-badge">channel has v' + escHtml(entry.upstream_version) + '</span>';
html += ' <span style="color:var(--text-dim);font-size:0.75rem">' +
'your own copy is used while it is present</span>';
}
if (source === "managed") {
html += ' <button class="btn-add drv-module-rollback" type="button" data-driver-id="' + escHtml(entry.id) +
'" data-logical-path="' + escHtml(entry.path) + '">Rollback</button>';
}
// Rollback only steps back one version. The channel keeps every
// version it has ever signed, so let an operator pick from them.
if (source !== "local") {
html += ' <button class="btn-add drv-module-versions" type="button" data-driver-id="' +
escHtml(entry.id) + '">Versions…</button>';
}
html += ' <span class="drv-module-action"></span>';
html += '<div class="drv-module-versions-panel" style="display:none;margin-top:6px"></div>';
slot.innerHTML = html;
});
bodyEl.querySelectorAll(".drv-module-update").forEach(function (btn) {
Expand Down Expand Up @@ -481,6 +581,25 @@
.catch(function (err) { if (status) status.textContent = " " + err.message; btn.disabled = false; });
});
});
bodyEl.querySelectorAll(".drv-module-versions").forEach(function (btn) {
btn.addEventListener("click", function () {
var panel = btn.parentElement.querySelector(".drv-module-versions-panel");
if (!panel) return;
if (panel.style.display !== "none") { panel.style.display = "none"; return; }
panel.style.display = "block";
panel.textContent = "Loading versions…";
var id = btn.dataset.driverId;
apiFetch("/api/device_repository/drivers/" + encodeURIComponent(id) + "/versions")
.then(function (r) {
return r.json().then(function (body) {
if (!r.ok) throw new Error(body.error || "could not list versions");
return body;
});
})
.then(function (body) { renderVersionPicker(panel, id, body); })
.catch(function (err) { panel.textContent = err.message; });
});
});
bodyEl.querySelectorAll(".drv-profile-slot").forEach(function (slot) {
var dIdx = parseInt(slot.getAttribute("data-driver-idx"), 10);
var d = config.drivers[dIdx];
Expand Down