metropolis: replace version stamp with product info

This removes the stamped metropolis version library and the associated
stampgo infrastructure, and replaces it with the product info file.

The info is now stored in a separate file in the rootfs, instead of
embedded in the core binary. This has the benefit that the core binary
no longer needs to be relinked when stamping info changes.

The version logging in core/main, and the tconsole are updated to show
some of the additional info from the product info.

Change-Id: Ic5ed0e3598e8da71b96748e8d7abfedff41acd3f
Reviewed-on: https://review.monogon.dev/c/monogon/+/4207
Tested-by: Jenkins CI
Reviewed-by: Tim Windelschmidt <tim@monogon.tech>
diff --git a/metropolis/node/BUILD.bazel b/metropolis/node/BUILD.bazel
index f267957..60368ff 100644
--- a/metropolis/node/BUILD.bazel
+++ b/metropolis/node/BUILD.bazel
@@ -54,6 +54,9 @@
         "/init": "//metropolis/node/minit",
         "/core": "//metropolis/node/core",
 
+        # Product info
+        "/etc/product-info.json": ":product_info",
+
         # CA Certificate bundle & os-release & resolv.conf & hosts
         # These should not be explicitly used by Metropolis code and are only here for compatibility with
         # paths hardcoded by standard libraries (like Go's).
@@ -172,6 +175,7 @@
     os_name = "Metropolis Node",
     out_os_release = ":product_info_os_release",
     stamp_var = "STABLE_MONOGON_metropolis_version",
+    visibility = [":__subpackages__"],
 )
 
 go_test(
diff --git a/metropolis/node/core/BUILD.bazel b/metropolis/node/core/BUILD.bazel
index 4e0b3b8..de07e8c 100644
--- a/metropolis/node/core/BUILD.bazel
+++ b/metropolis/node/core/BUILD.bazel
@@ -30,20 +30,19 @@
         "//metropolis/node/core/metrics",
         "//metropolis/node/core/mgmt",
         "//metropolis/node/core/network",
+        "//metropolis/node/core/productinfo",
         "//metropolis/node/core/roleserve",
         "//metropolis/node/core/rpc/resolver",
         "//metropolis/node/core/tconsole",
         "//metropolis/node/core/time",
         "//metropolis/node/core/update",
         "//metropolis/proto/api",
-        "//metropolis/version",
         "//osbase/bringup",
         "//osbase/logtree",
         "//osbase/net/dns",
         "//osbase/supervisor",
         "//osbase/sysctl",
         "//osbase/tpm",
-        "//version",
         "@com_github_cenkalti_backoff_v4//:backoff",
         "@com_github_containerd_containerd_v2//client",
         "@com_github_containerd_containerd_v2//pkg/namespaces",
diff --git a/metropolis/node/core/main.go b/metropolis/node/core/main.go
index 890a30b..2d80698 100644
--- a/metropolis/node/core/main.go
+++ b/metropolis/node/core/main.go
@@ -17,19 +17,18 @@
 	"source.monogon.dev/metropolis/node/core/localstorage/declarative"
 	"source.monogon.dev/metropolis/node/core/metrics"
 	"source.monogon.dev/metropolis/node/core/network"
+	"source.monogon.dev/metropolis/node/core/productinfo"
 	"source.monogon.dev/metropolis/node/core/roleserve"
 	"source.monogon.dev/metropolis/node/core/rpc/resolver"
 	"source.monogon.dev/metropolis/node/core/tconsole"
 	timesvc "source.monogon.dev/metropolis/node/core/time"
 	"source.monogon.dev/metropolis/node/core/update"
-	mversion "source.monogon.dev/metropolis/version"
 	"source.monogon.dev/osbase/bringup"
 	"source.monogon.dev/osbase/logtree"
 	"source.monogon.dev/osbase/net/dns"
 	"source.monogon.dev/osbase/supervisor"
 	"source.monogon.dev/osbase/sysctl"
 	"source.monogon.dev/osbase/tpm"
-	"source.monogon.dev/version"
 )
 
 func main() {
@@ -95,8 +94,17 @@
 func root(ctx context.Context) error {
 	logger := supervisor.Logger(ctx)
 
-	logger.Info("Starting Metropolis node init")
-	logger.Infof("Version: %s", version.Semver(mversion.Version))
+	productInfo := productinfo.Get()
+	logger.Infof("Starting %s init", productInfo.Info.Name)
+	logger.Infof("Version: %s", productInfo.VersionString)
+	logger.Infof("Variant: %s", productInfo.Info.Variant)
+	if productInfo.Info.BuildTreeDirty {
+		logger.Warning("Build tree dirty")
+	}
+	if productInfo.Info.CommitHash != "" {
+		logger.Infof("Commit: %s", productInfo.Info.CommitHash)
+		logger.Infof("Commit date: %s", productInfo.HumanCommitDate)
+	}
 
 	// Linux kernel default is 4096 which is far too low. Raise it to 1M which
 	// is what gVisor suggests.
@@ -198,8 +206,15 @@
 
 	// Initialize interactive consoles.
 	interactiveConsoles := []string{"/dev/tty0"}
+	consoleConfig := tconsole.Config{
+		Terminal:    tconsole.TerminalLinux,
+		LogTree:     supervisor.LogTree(ctx),
+		Network:     &networkSvc.Status,
+		Roles:       &rs.LocalRoles,
+		CuratorConn: &rs.CuratorConnection,
+	}
 	for _, c := range interactiveConsoles {
-		console, err := tconsole.New(tconsole.TerminalLinux, c, supervisor.LogTree(ctx), &networkSvc.Status, &rs.LocalRoles, &rs.CuratorConnection)
+		console, err := tconsole.New(consoleConfig, c)
 		if err != nil {
 			logger.Infof("Failed to initialize interactive console at %s: %v", c, err)
 		} else {
diff --git a/metropolis/node/core/productinfo/BUILD.bazel b/metropolis/node/core/productinfo/BUILD.bazel
new file mode 100644
index 0000000..c34350e
--- /dev/null
+++ b/metropolis/node/core/productinfo/BUILD.bazel
@@ -0,0 +1,18 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "productinfo",
+    srcs = ["productinfo.go"],
+    importpath = "source.monogon.dev/metropolis/node/core/productinfo",
+    visibility = [
+        "//metropolis/node/core:__subpackages__",
+        "//metropolis/node/kubernetes:__subpackages__",
+    ],
+    deps = [
+        "//osbase/oci/osimage",
+        "//version",
+        "//version/spec",
+        "@com_github_coreos_go_semver//semver",
+        "@io_bazel_rules_go//go/runfiles",
+    ],
+)
diff --git a/metropolis/node/core/productinfo/productinfo.go b/metropolis/node/core/productinfo/productinfo.go
new file mode 100644
index 0000000..5d66760
--- /dev/null
+++ b/metropolis/node/core/productinfo/productinfo.go
@@ -0,0 +1,147 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package productinfo
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"regexp"
+	"strconv"
+	"sync"
+	"time"
+
+	"github.com/bazelbuild/rules_go/go/runfiles"
+	"github.com/coreos/go-semver/semver"
+
+	vpb "source.monogon.dev/version/spec"
+
+	"source.monogon.dev/osbase/oci/osimage"
+	"source.monogon.dev/version"
+)
+
+// ProductInfo is a wrapper of [osimage.ProductInfo] with some additional
+// representations of the same data.
+type ProductInfo struct {
+	// Info is the product info parsed from JSON.
+	Info *osimage.ProductInfo
+	// Version parsed as [vpb.Version]
+	Version *vpb.Version
+	// VersionString is Info.Version with short commit and dirty indicator.
+	VersionString string
+	// HumanCommitDate is Info.CommitDate formatted to be more readable, in UTC.
+	// This is empty if stamping is disabled.
+	HumanCommitDate string
+}
+
+// path to the product info file. This may be replaced by x_defs in tests, and
+// will then be resolved with runfiles.
+var path = "/etc/product-info.json"
+
+// Get returns the product info of the running system.
+var Get = sync.OnceValue(func() *ProductInfo {
+	resolvedPath := path
+	if resolvedPath[0] != '/' {
+		var err error
+		resolvedPath, err = runfiles.Rlocation(resolvedPath)
+		if err != nil {
+			panic(err)
+		}
+	}
+	productInfo, err := read(resolvedPath)
+	if err != nil {
+		panic(err)
+	}
+	return productInfo
+})
+
+// read parses the JSON file at the given path as [osimage.ProductInfo].
+func read(path string) (*ProductInfo, error) {
+	rawProductInfo, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var productInfo *osimage.ProductInfo
+	err = json.Unmarshal(rawProductInfo, &productInfo)
+	if err != nil {
+		return nil, err
+	}
+
+	specVersion, err := versionFromProductInfo(productInfo)
+	if err != nil {
+		return nil, fmt.Errorf("failed to extract version: %w", err)
+	}
+
+	var humanCommitDate string
+	if productInfo.CommitDate != "" {
+		commitDate, err := time.Parse(time.RFC3339, productInfo.CommitDate)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse commit date: %w", err)
+		}
+		humanCommitDate = commitDate.UTC().Format(time.DateTime)
+	}
+
+	info := &ProductInfo{
+		Info:            productInfo,
+		Version:         specVersion,
+		VersionString:   version.Semver(specVersion),
+		HumanCommitDate: humanCommitDate,
+	}
+	return info, nil
+}
+
+var rePrereleaseCommitOffset = regexp.MustCompile(`^dev([0-9]+)$`)
+
+func versionFromProductInfo(productInfo *osimage.ProductInfo) (*vpb.Version, error) {
+	version := &vpb.Version{}
+
+	if productInfo.CommitHash != "" {
+		if len(productInfo.CommitHash) < 8 {
+			return nil, fmt.Errorf("git commit hash too short")
+		}
+		buildTreeState := vpb.Version_GitInformation_BUILD_TREE_STATE_CLEAN
+		if productInfo.BuildTreeDirty {
+			buildTreeState = vpb.Version_GitInformation_BUILD_TREE_STATE_DIRTY
+		}
+		version.GitInformation = &vpb.Version_GitInformation{
+			CommitHash:     productInfo.CommitHash[:8],
+			BuildTreeState: buildTreeState,
+		}
+	}
+
+	if productInfo.Version != "" {
+		v, err := semver.NewVersion(productInfo.Version)
+		if err != nil {
+			return nil, fmt.Errorf("invalid %s version %q: %w", productInfo.ID, productInfo.Version, err)
+		}
+		// Parse prerelease strings (v1.2.3-foo-bar -> [foo, bar])
+		for _, el := range v.PreRelease.Slice() {
+			preCommitOffset := rePrereleaseCommitOffset.FindStringSubmatch(el)
+			switch {
+			case el == "":
+				// Skip empty slices which happens when there's a semver string with no
+				// prerelease data.
+			case el == "nostamp":
+				// Ignore field, we have it from CommitHash.
+			case preCommitOffset != nil:
+				offset, err := strconv.ParseUint(preCommitOffset[1], 10, 64)
+				if err != nil {
+					return nil, fmt.Errorf("invalid commit offset value: %w", err)
+				}
+				if version.GitInformation == nil {
+					return nil, fmt.Errorf("have git offset but no git commit")
+				}
+				version.GitInformation.CommitsSinceRelease = offset
+			default:
+				return nil, fmt.Errorf("invalid prerelease string %q (in %q)", el, productInfo.Version)
+			}
+		}
+		version.Release = &vpb.Version_Release{
+			Major: v.Major,
+			Minor: v.Minor,
+			Patch: v.Patch,
+		}
+	}
+	return version, nil
+}
diff --git a/metropolis/node/core/roleserve/BUILD.bazel b/metropolis/node/core/roleserve/BUILD.bazel
index 1fb4719..37ec6d1 100644
--- a/metropolis/node/core/roleserve/BUILD.bazel
+++ b/metropolis/node/core/roleserve/BUILD.bazel
@@ -30,6 +30,7 @@
         "//metropolis/node/core/mgmt",
         "//metropolis/node/core/network",
         "//metropolis/node/core/network/hostsfile",
+        "//metropolis/node/core/productinfo",
         "//metropolis/node/core/rpc",
         "//metropolis/node/core/rpc/resolver",
         "//metropolis/node/core/update",
@@ -38,7 +39,6 @@
         "//metropolis/node/kubernetes/pki",
         "//metropolis/proto/api",
         "//metropolis/proto/common",
-        "//metropolis/version",
         "//osbase/event",
         "//osbase/event/memory",
         "//osbase/logtree",
@@ -55,17 +55,23 @@
 go_test(
     name = "roleserve_test",
     srcs = ["worker_statuspush_test.go"],
+    data = [
+        "//metropolis/node:product_info",
+    ],
     embed = [":roleserve"],
     # TODO: https://github.com/monogon-dev/monogon/issues/250
     flaky = True,
+    x_defs = {
+        "source.monogon.dev/metropolis/node/core/productinfo.path": "$(rlocationpath //metropolis/node:product_info )",
+    },
     deps = [
         "//metropolis/node",
         "//metropolis/node/core/consensus",
         "//metropolis/node/core/curator",
         "//metropolis/node/core/curator/proto/api",
+        "//metropolis/node/core/productinfo",
         "//metropolis/proto/common",
         "//metropolis/test/util",
-        "//metropolis/version",
         "//osbase/supervisor",
         "@com_github_cenkalti_backoff_v4//:backoff",
         "@com_github_google_go_cmp//cmp",
diff --git a/metropolis/node/core/roleserve/worker_statuspush.go b/metropolis/node/core/roleserve/worker_statuspush.go
index 0557b4c..62355bc 100644
--- a/metropolis/node/core/roleserve/worker_statuspush.go
+++ b/metropolis/node/core/roleserve/worker_statuspush.go
@@ -14,7 +14,7 @@
 
 	common "source.monogon.dev/metropolis/node"
 	"source.monogon.dev/metropolis/node/core/network"
-	"source.monogon.dev/metropolis/version"
+	"source.monogon.dev/metropolis/node/core/productinfo"
 	"source.monogon.dev/osbase/event"
 	"source.monogon.dev/osbase/event/memory"
 	"source.monogon.dev/osbase/supervisor"
@@ -64,7 +64,7 @@
 // workerStatusPushChannels.
 func workerStatusPushLoop(ctx context.Context, chans *workerStatusPushChannels) error {
 	status := cpb.NodeStatus{
-		Version: version.Version,
+		Version: productinfo.Get().Version,
 		BootId:  getBootID(ctx),
 	}
 
diff --git a/metropolis/node/core/roleserve/worker_statuspush_test.go b/metropolis/node/core/roleserve/worker_statuspush_test.go
index 78c5f9c..40764f2 100644
--- a/metropolis/node/core/roleserve/worker_statuspush_test.go
+++ b/metropolis/node/core/roleserve/worker_statuspush_test.go
@@ -21,8 +21,8 @@
 	common "source.monogon.dev/metropolis/node"
 	"source.monogon.dev/metropolis/node/core/consensus"
 	"source.monogon.dev/metropolis/node/core/curator"
+	"source.monogon.dev/metropolis/node/core/productinfo"
 	"source.monogon.dev/metropolis/test/util"
-	mversion "source.monogon.dev/metropolis/version"
 	"source.monogon.dev/osbase/supervisor"
 
 	ipb "source.monogon.dev/metropolis/node/core/curator/proto/api"
@@ -86,6 +86,9 @@
 	getBootID = func(ctx context.Context) []byte {
 		return []byte{1, 2, 3}
 	}
+
+	productInfo := productinfo.Get()
+
 	chans := workerStatusPushChannels{
 		address:           make(chan string),
 		localControlPlane: make(chan *localControlPlane),
@@ -135,7 +138,7 @@
 	cur.expectReports(t, []*ipb.UpdateNodeStatusRequest{
 		{NodeId: nodeID, Status: &cpb.NodeStatus{
 			ExternalAddress: "192.0.2.10",
-			Version:         mversion.Version,
+			Version:         productInfo.Version,
 			BootId:          []byte{1, 2, 3},
 		}},
 	})
@@ -146,12 +149,12 @@
 	cur.expectReports(t, []*ipb.UpdateNodeStatusRequest{
 		{NodeId: nodeID, Status: &cpb.NodeStatus{
 			ExternalAddress: "192.0.2.10",
-			Version:         mversion.Version,
+			Version:         productInfo.Version,
 			BootId:          []byte{1, 2, 3},
 		}},
 		{NodeId: nodeID, Status: &cpb.NodeStatus{
 			ExternalAddress: "192.0.2.11",
-			Version:         mversion.Version,
+			Version:         productInfo.Version,
 			BootId:          []byte{1, 2, 3},
 		}},
 	})
@@ -168,12 +171,12 @@
 	cur.expectReports(t, []*ipb.UpdateNodeStatusRequest{
 		{NodeId: nodeID, Status: &cpb.NodeStatus{
 			ExternalAddress: "192.0.2.10",
-			Version:         mversion.Version,
+			Version:         productInfo.Version,
 			BootId:          []byte{1, 2, 3},
 		}},
 		{NodeId: nodeID, Status: &cpb.NodeStatus{
 			ExternalAddress: "192.0.2.11",
-			Version:         mversion.Version,
+			Version:         productInfo.Version,
 			BootId:          []byte{1, 2, 3},
 		}},
 		{NodeId: nodeID, Status: &cpb.NodeStatus{
@@ -181,12 +184,12 @@
 			RunningCurator: &cpb.NodeStatus_RunningCurator{
 				Port: int32(common.CuratorServicePort),
 			},
-			Version: mversion.Version,
+			Version: productInfo.Version,
 			BootId:  []byte{1, 2, 3},
 		}},
 		{NodeId: nodeID, Status: &cpb.NodeStatus{
 			ExternalAddress: "192.0.2.11",
-			Version:         mversion.Version,
+			Version:         productInfo.Version,
 			BootId:          []byte{1, 2, 3},
 		}},
 	})
diff --git a/metropolis/node/core/tconsole/BUILD.bazel b/metropolis/node/core/tconsole/BUILD.bazel
index f67e9b9..c551760 100644
--- a/metropolis/node/core/tconsole/BUILD.bazel
+++ b/metropolis/node/core/tconsole/BUILD.bazel
@@ -17,13 +17,12 @@
     visibility = ["//visibility:public"],
     deps = [
         "//metropolis/node/core/network",
+        "//metropolis/node/core/productinfo",
         "//metropolis/node/core/roleserve",
         "//metropolis/proto/common",
-        "//metropolis/version",
         "//osbase/event",
         "//osbase/logtree",
         "//osbase/supervisor",
-        "//version",
         "@com_github_gdamore_tcell_v2//:tcell",
         "@com_github_rivo_uniseg//:uniseg",
     ],
diff --git a/metropolis/node/core/tconsole/page_status.go b/metropolis/node/core/tconsole/page_status.go
index 7a139ac..482dd6e 100644
--- a/metropolis/node/core/tconsole/page_status.go
+++ b/metropolis/node/core/tconsole/page_status.go
@@ -10,8 +10,7 @@
 
 	"github.com/gdamore/tcell/v2"
 
-	mversion "source.monogon.dev/metropolis/version"
-	"source.monogon.dev/version"
+	"source.monogon.dev/metropolis/node/core/productinfo"
 )
 
 //go:embed build/copyright_line.txt
@@ -49,13 +48,27 @@
 	splitH := split(c.width, logoWidth, 60)
 
 	// Status lines.
+	productInfo := productinfo.Get()
 	lines := []string{
-		fmt.Sprintf("Version: %s", version.Semver(mversion.Version)),
+		fmt.Sprintf("Version: %s", productInfo.VersionString),
+		fmt.Sprintf("Variant: %s", productInfo.Info.Variant),
+	}
+	if productInfo.Info.BuildTreeDirty {
+		lines = append(lines, "Build tree dirty")
+	}
+	if productInfo.Info.CommitHash != "" {
+		lines = append(lines,
+			fmt.Sprintf("Commit: %s", productInfo.Info.CommitHash),
+			fmt.Sprintf("Commit date: %s", productInfo.HumanCommitDate),
+		)
+	}
+	lines = append(lines,
+		"", // Blank line between product info and node info.
 		fmt.Sprintf("Node ID: %s", d.id),
 		fmt.Sprintf("CA fingerprint: %s", d.fingerprint),
 		fmt.Sprintf("Management address: %s", d.netAddr),
 		fmt.Sprintf("Roles: %s", d.roles),
-	}
+	)
 	// Calculate longest line.
 	maxLine := 0
 	for _, l := range lines {
diff --git a/metropolis/node/core/tconsole/standalone/BUILD.bazel b/metropolis/node/core/tconsole/standalone/BUILD.bazel
index bc48d04..e9ed29a 100644
--- a/metropolis/node/core/tconsole/standalone/BUILD.bazel
+++ b/metropolis/node/core/tconsole/standalone/BUILD.bazel
@@ -3,8 +3,14 @@
 go_library(
     name = "standalone_lib",
     srcs = ["main.go"],
+    data = [
+        "//metropolis/node:product_info",
+    ],
     importpath = "source.monogon.dev/metropolis/node/core/tconsole/standalone",
     visibility = ["//visibility:private"],
+    x_defs = {
+        "source.monogon.dev/metropolis/node/core/productinfo.path": "$(rlocationpath //metropolis/node:product_info )",
+    },
     deps = [
         "//metropolis/node/core/network",
         "//metropolis/node/core/roleserve",
diff --git a/metropolis/node/core/tconsole/standalone/main.go b/metropolis/node/core/tconsole/standalone/main.go
index d9ceb18..eaf2c47 100644
--- a/metropolis/node/core/tconsole/standalone/main.go
+++ b/metropolis/node/core/tconsole/standalone/main.go
@@ -33,8 +33,14 @@
 	var curV memory.Value[*roleserve.CuratorConnection]
 
 	lt := logtree.New()
-
-	tc, err := tconsole.New(tconsole.TerminalGeneric, "/proc/self/fd/0", lt, &netV, &rolesV, &curV)
+	config := tconsole.Config{
+		Terminal:    tconsole.TerminalGeneric,
+		LogTree:     lt,
+		Network:     &netV,
+		Roles:       &rolesV,
+		CuratorConn: &curV,
+	}
+	tc, err := tconsole.New(config, "/proc/self/fd/0")
 	if err != nil {
 		log.Fatalf("tconsole.New: %v", err)
 	}
@@ -98,7 +104,7 @@
 		supervisor.Signal(ctx, supervisor.SignalHealthy)
 		<-ctx.Done()
 		return ctx.Err()
-	}, supervisor.WithExistingLogtree(lt))
+	}, supervisor.WithExistingLogtree(lt), supervisor.WithPropagatePanic)
 	<-ctx.Done()
 	tc.Cleanup()
 }
diff --git a/metropolis/node/core/tconsole/tconsole.go b/metropolis/node/core/tconsole/tconsole.go
index aa3df7b..eeb4536 100644
--- a/metropolis/node/core/tconsole/tconsole.go
+++ b/metropolis/node/core/tconsole/tconsole.go
@@ -21,12 +21,19 @@
 	"source.monogon.dev/osbase/supervisor"
 )
 
+type Config struct {
+	Terminal    Terminal
+	LogTree     *logtree.LogTree
+	Network     event.Value[*network.Status]
+	Roles       event.Value[*cpb.NodeRoles]
+	CuratorConn event.Value[*roleserve.CuratorConnection]
+}
+
 // Console is a Terminal Console (TConsole), a user-interactive informational
 // display visible on the TTY of a running Metropolis instance.
 type Console struct {
 	// Quit will be closed when the user press CTRL-C. A new channel will be created
 	// on each New call.
-
 	Quit    chan struct{}
 	ttyPath string
 	tty     tcell.Tty
@@ -39,10 +46,8 @@
 	// constructed dynamically in Run.
 	activePage int
 
-	reader      *logtree.LogReader
-	network     event.Value[*network.Status]
-	roles       event.Value[*cpb.NodeRoles]
-	curatorConn event.Value[*roleserve.CuratorConnection]
+	config Config
+	reader *logtree.LogReader
 }
 
 // New creates a new Console, taking over the TTY at the given path. The given
@@ -51,8 +56,8 @@
 //
 // network, roles, curatorConn point to various Metropolis subsystems that are
 // used to populate the console data.
-func New(terminal Terminal, ttyPath string, lt *logtree.LogTree, network event.Value[*network.Status], roles event.Value[*cpb.NodeRoles], curatorConn event.Value[*roleserve.CuratorConnection]) (*Console, error) {
-	reader, err := lt.Read("", logtree.WithChildren(), logtree.WithStream())
+func New(config Config, ttyPath string) (*Console, error) {
+	reader, err := config.LogTree.Read("", logtree.WithChildren(), logtree.WithStream())
 	if err != nil {
 		return nil, fmt.Errorf("lt.Read: %w", err)
 	}
@@ -71,7 +76,7 @@
 	screen.SetStyle(tcell.StyleDefault)
 
 	var pal palette
-	switch terminal {
+	switch config.Terminal {
 	case TerminalLinux:
 		pal = paletteLinux
 		tty.Write([]byte(pal.setupLinuxConsole()))
@@ -87,14 +92,11 @@
 		screen:     screen,
 		width:      width,
 		height:     height,
-		network:    network,
 		palette:    pal,
 		Quit:       make(chan struct{}),
 		activePage: 0,
+		config:     config,
 		reader:     reader,
-
-		roles:       roles,
-		curatorConn: curatorConn,
 	}, nil
 }
 
@@ -131,13 +133,13 @@
 	netAddrC := make(chan *network.Status)
 	rolesC := make(chan *cpb.NodeRoles)
 	curatorConnC := make(chan *roleserve.CuratorConnection)
-	if err := supervisor.Run(ctx, "netpipe", event.Pipe(c.network, netAddrC)); err != nil {
+	if err := supervisor.Run(ctx, "netpipe", event.Pipe(c.config.Network, netAddrC)); err != nil {
 		return err
 	}
-	if err := supervisor.Run(ctx, "rolespipe", event.Pipe(c.roles, rolesC)); err != nil {
+	if err := supervisor.Run(ctx, "rolespipe", event.Pipe(c.config.Roles, rolesC)); err != nil {
 		return err
 	}
-	if err := supervisor.Run(ctx, "curatorpipe", event.Pipe(c.curatorConn, curatorConnC)); err != nil {
+	if err := supervisor.Run(ctx, "curatorpipe", event.Pipe(c.config.CuratorConn, curatorConnC)); err != nil {
 		return err
 	}
 	supervisor.Signal(ctx, supervisor.SignalHealthy)
diff --git a/metropolis/node/core/update/BUILD.bazel b/metropolis/node/core/update/BUILD.bazel
index 532b917..0ce75b4 100644
--- a/metropolis/node/core/update/BUILD.bazel
+++ b/metropolis/node/core/update/BUILD.bazel
@@ -11,8 +11,8 @@
     deps = [
         "//go/logging",
         "//metropolis/node/abloader/spec",
+        "//metropolis/node/core/productinfo",
         "//metropolis/proto/api",
-        "//metropolis/version",
         "//osbase/blockdev",
         "//osbase/build/mkimage/osimage",
         "//osbase/efivarfs",
@@ -20,7 +20,6 @@
         "//osbase/kexec",
         "//osbase/oci/osimage",
         "//osbase/oci/registry",
-        "//version",
         "@com_github_cenkalti_backoff_v4//:backoff",
         "@org_golang_google_grpc//codes",
         "@org_golang_google_grpc//status",
diff --git a/metropolis/node/core/update/e2e/testos/testos.bzl b/metropolis/node/core/update/e2e/testos/testos.bzl
index c59af8c..b0665c0 100644
--- a/metropolis/node/core/update/e2e/testos/testos.bzl
+++ b/metropolis/node/core/update/e2e/testos/testos.bzl
@@ -12,6 +12,7 @@
         name = "rootfs_" + variant,
         files = {
             "/init": ":testos_" + variant,
+            "/etc/product-info.json": ":product_info_" + variant,
             "/etc/resolv.conf": "//osbase/net/dns:resolv.conf",
         },
         fsspecs = [
diff --git a/metropolis/node/core/update/update.go b/metropolis/node/core/update/update.go
index 53bcb96..d620677 100644
--- a/metropolis/node/core/update/update.go
+++ b/metropolis/node/core/update/update.go
@@ -26,7 +26,7 @@
 	"google.golang.org/protobuf/proto"
 
 	"source.monogon.dev/go/logging"
-	mversion "source.monogon.dev/metropolis/version"
+	"source.monogon.dev/metropolis/node/core/productinfo"
 	"source.monogon.dev/osbase/blockdev"
 	"source.monogon.dev/osbase/build/mkimage/osimage"
 	"source.monogon.dev/osbase/efivarfs"
@@ -34,7 +34,6 @@
 	"source.monogon.dev/osbase/kexec"
 	ociosimage "source.monogon.dev/osbase/oci/osimage"
 	"source.monogon.dev/osbase/oci/registry"
-	"source.monogon.dev/version"
 
 	abloaderpb "source.monogon.dev/metropolis/node/abloader/spec"
 	apb "source.monogon.dev/metropolis/proto/api"
@@ -284,7 +283,7 @@
 		RetryNotify: func(err error, d time.Duration) {
 			s.Logger.Warningf("Error while fetching OS image, retrying in %v: %v", d, err)
 		},
-		UserAgent:  "MonogonOS/" + version.Semver(mversion.Version),
+		UserAgent:  "MonogonOS/" + productinfo.Get().VersionString,
 		Scheme:     imageRef.Scheme,
 		Host:       imageRef.Host,
 		Repository: imageRef.Repository,
diff --git a/metropolis/node/kubernetes/reconciler/BUILD.bazel b/metropolis/node/kubernetes/reconciler/BUILD.bazel
index 3e66db3..caac358 100644
--- a/metropolis/node/kubernetes/reconciler/BUILD.bazel
+++ b/metropolis/node/kubernetes/reconciler/BUILD.bazel
@@ -16,7 +16,7 @@
         "//metropolis/node/core/consensus/client",
         "//metropolis/node/core/curator",
         "//metropolis/node/core/curator/proto/private",
-        "//metropolis/version",
+        "//metropolis/node/core/productinfo",
         "//osbase/event/etcd",
         "//osbase/event/memory",
         "//osbase/supervisor",
@@ -46,13 +46,19 @@
         "reconciler_status_test.go",
         "reconciler_test.go",
     ],
+    data = [
+        "//metropolis/node:product_info",
+    ],
     embed = [":reconciler"],
+    x_defs = {
+        "source.monogon.dev/metropolis/node/core/productinfo.path": "$(rlocationpath //metropolis/node:product_info )",
+    },
     deps = [
         "//metropolis/node/core/consensus/client",
         "//metropolis/node/core/curator",
         "//metropolis/node/core/curator/proto/private",
+        "//metropolis/node/core/productinfo",
         "//metropolis/proto/common",
-        "//metropolis/version",
         "//osbase/supervisor",
         "//version",
         "//version/spec",
diff --git a/metropolis/node/kubernetes/reconciler/reconciler_status.go b/metropolis/node/kubernetes/reconciler/reconciler_status.go
index b516207..914e4c2 100644
--- a/metropolis/node/kubernetes/reconciler/reconciler_status.go
+++ b/metropolis/node/kubernetes/reconciler/reconciler_status.go
@@ -19,7 +19,7 @@
 	"source.monogon.dev/metropolis/node/core/consensus/client"
 	"source.monogon.dev/metropolis/node/core/curator"
 	ppb "source.monogon.dev/metropolis/node/core/curator/proto/private"
-	mversion "source.monogon.dev/metropolis/version"
+	"source.monogon.dev/metropolis/node/core/productinfo"
 	"source.monogon.dev/osbase/event/etcd"
 	"source.monogon.dev/osbase/event/memory"
 	"source.monogon.dev/osbase/supervisor"
@@ -69,8 +69,8 @@
 
 // WaitReady watches the reconciler status and returns once initial
 // reconciliation is done and the reconciled state is compatible.
-func WaitReady(ctx context.Context, etcdClient client.Namespaced) error {
-	value := etcd.NewValue(etcdClient, statusKey, func(_, data []byte) (*ppb.KubernetesReconcilerStatus, error) {
+func (s *Service) WaitReady(ctx context.Context) error {
+	value := etcd.NewValue(s.Etcd, statusKey, func(_, data []byte) (*ppb.KubernetesReconcilerStatus, error) {
 		status := &ppb.KubernetesReconcilerStatus{}
 		if err := proto.Unmarshal(data, status); err != nil {
 			return nil, fmt.Errorf("could not unmarshal: %w", err)
@@ -98,11 +98,11 @@
 			state,
 			version.Semver(status.Version),
 			version.Release(status.MinimumCompatibleRelease),
-			version.Semver(mversion.Version),
+			version.Semver(productinfo.Get().Version),
 			version.Release(minReconcilerRelease),
 		)
 
-		if version.ReleaseLessThan(mversion.Version.Release, status.MinimumCompatibleRelease) {
+		if version.ReleaseLessThan(productinfo.Get().Version.Release, status.MinimumCompatibleRelease) {
 			supervisor.Logger(ctx).Info("Not ready, because the local node release is below the reconciler minimum compatible release. Waiting for status change.")
 			continue
 		}
@@ -218,8 +218,8 @@
 	}
 
 	publish := func() {
-		minRelease := mversion.Version.Release
-		maxRelease := mversion.Version.Release
+		minRelease := productinfo.Get().Version.Release
+		maxRelease := productinfo.Get().Version.Release
 		for _, release := range releaseStruct {
 			if version.ReleaseLessThan(release, minRelease) {
 				minRelease = release
@@ -275,7 +275,7 @@
 		return err
 	}
 
-	shouldRun := !version.ReleaseLessThan(mversion.Version.Release, r.maxRelease)
+	shouldRun := !version.ReleaseLessThan(productinfo.Get().Version.Release, r.maxRelease)
 	if shouldRun {
 		supervisor.Logger(ctx).Info("This Kubernetes controller node has the latest release, starting election.")
 		err := supervisor.Run(ctx, "elect", s.elect)
@@ -293,7 +293,7 @@
 		if err != nil {
 			return err
 		}
-		shouldRunNow := !version.ReleaseLessThan(mversion.Version.Release, r.maxRelease)
+		shouldRunNow := !version.ReleaseLessThan(productinfo.Get().Version.Release, r.maxRelease)
 		if shouldRunNow != shouldRun {
 			return errors.New("latest release changed, restarting")
 		}
@@ -360,7 +360,7 @@
 
 	doneStatus := &ppb.KubernetesReconcilerStatus{
 		State:                    ppb.KubernetesReconcilerStatus_STATE_DONE,
-		Version:                  mversion.Version,
+		Version:                  productinfo.Get().Version,
 		MinimumCompatibleRelease: minApiserverRelease,
 	}
 	doneStatusBytes, err := proto.Marshal(doneStatus)
@@ -374,7 +374,7 @@
 	} else if proto.Equal(status, doneStatus) {
 		// The status is already what we would set, so leave it as is.
 		log.Info("Status is already up to date.")
-	} else if !version.ReleaseLessThan(mversion.Version.Release, status.Version.Release) &&
+	} else if !version.ReleaseLessThan(productinfo.Get().Version.Release, status.Version.Release) &&
 		!version.ReleaseLessThan(status.MinimumCompatibleRelease, minApiserverRelease) {
 		// The status does not allow apiservers to start serving which would be
 		// incompatible after we reconcile. So just set the state to working.
@@ -408,8 +408,8 @@
 		log.Info("Status allows incompatible releases, need to restrict.")
 
 		status.State = ppb.KubernetesReconcilerStatus_STATE_WORKING
-		if !version.ReleaseLessThan(status.Version.Release, mversion.Version.Release) {
-			status.Version = mversion.Version
+		if !version.ReleaseLessThan(status.Version.Release, productinfo.Get().Version.Release) {
+			status.Version = productinfo.Get().Version
 		}
 		if version.ReleaseLessThan(status.MinimumCompatibleRelease, minApiserverRelease) {
 			status.MinimumCompatibleRelease = minApiserverRelease
@@ -428,7 +428,7 @@
 			if err != nil {
 				return err
 			}
-			if version.ReleaseLessThan(mversion.Version.Release, releases.maxRelease) {
+			if version.ReleaseLessThan(productinfo.Get().Version.Release, releases.maxRelease) {
 				// We will likely get canceled soon by watchReleases restarting, unless
 				// this is a very short transient that is not noticed by watchReleases.
 				continue
diff --git a/metropolis/node/kubernetes/reconciler/reconciler_status_test.go b/metropolis/node/kubernetes/reconciler/reconciler_status_test.go
index f9d839f..1f17e69 100644
--- a/metropolis/node/kubernetes/reconciler/reconciler_status_test.go
+++ b/metropolis/node/kubernetes/reconciler/reconciler_status_test.go
@@ -15,26 +15,28 @@
 	"source.monogon.dev/metropolis/node/core/consensus/client"
 	"source.monogon.dev/metropolis/node/core/curator"
 	ppb "source.monogon.dev/metropolis/node/core/curator/proto/private"
+	"source.monogon.dev/metropolis/node/core/productinfo"
 	cpb "source.monogon.dev/metropolis/proto/common"
-	mversion "source.monogon.dev/metropolis/version"
 	"source.monogon.dev/osbase/supervisor"
 	"source.monogon.dev/version"
 	vpb "source.monogon.dev/version/spec"
 )
 
+var productInfo = productinfo.Get()
+
 // TestMinimumReleasesNotAboveMetropolisRelease tests that minimum releases
 // are not above the metropolis release itself, because that would cause
 // things to get stuck.
 func TestMinimumReleasesNotAboveMetropolisRelease(t *testing.T) {
-	if version.ReleaseLessThan(mversion.Version.Release, minReconcilerRelease) {
+	if version.ReleaseLessThan(productInfo.Version.Release, minReconcilerRelease) {
 		t.Errorf("Metropolis release %s is below the minimum reconciler release %s",
-			version.Semver(mversion.Version),
+			version.Semver(productInfo.Version),
 			version.Release(minReconcilerRelease),
 		)
 	}
-	if version.ReleaseLessThan(mversion.Version.Release, minApiserverRelease) {
+	if version.ReleaseLessThan(productInfo.Version.Release, minApiserverRelease) {
 		t.Errorf("Metropolis release %s is below the minimum apiserver release %s",
-			version.Semver(mversion.Version),
+			version.Semver(productInfo.Version),
 			version.Release(minApiserverRelease),
 		)
 	}
@@ -115,10 +117,13 @@
 // will time out if WaitReady fails to return when it is supposed to.
 func TestWaitReady(t *testing.T) {
 	cl := startEtcd(t)
+	s := Service{
+		Etcd: cl,
+	}
 
 	isReady := make(chan struct{})
 	supervisor.TestHarness(t, func(ctx context.Context) error {
-		err := WaitReady(ctx, cl)
+		err := s.WaitReady(ctx)
 		if err != nil {
 			t.Error(err)
 		}
@@ -163,7 +168,7 @@
 		Version: &vpb.Version{
 			Release: &vpb.Version_Release{Major: 10000, Minor: 0, Patch: 0},
 		},
-		MinimumCompatibleRelease: mversion.Version.Release,
+		MinimumCompatibleRelease: productInfo.Version.Release,
 	})
 
 	<-isReady
@@ -263,7 +268,7 @@
 	waitForActions := func() {
 		isReady := make(chan struct{})
 		supervisor.TestHarness(t, func(ctx context.Context) error {
-			err := WaitReady(ctx, cl)
+			err := s.WaitReady(ctx)
 			if err != nil {
 				t.Error(err)
 			}
diff --git a/metropolis/node/kubernetes/service_controller.go b/metropolis/node/kubernetes/service_controller.go
index b91c455..cd9fdbf 100644
--- a/metropolis/node/kubernetes/service_controller.go
+++ b/metropolis/node/kubernetes/service_controller.go
@@ -162,7 +162,7 @@
 	// starting the scheduler/controller-manager/etc just to get them to immediately
 	// fail and back off with 'unauthorized'.
 	supervisor.Logger(ctx).Info("Waiting for reconciler...")
-	err = reconciler.WaitReady(ctx, etcd)
+	err = reconcilerService.WaitReady(ctx)
 	if err != nil {
 		return fmt.Errorf("while waiting for reconciler: %w", err)
 	}