treewide: build product info
This change adds the type definition and generator for product info,
which will be added to the OCI OS image to provide information about the
contents.
Here is an example product info:
{
"id": "metropolis-node",
"name": "Metropolis Node",
"version": "0.1.0-dev1059",
"variant": "x86_64-race",
"commit_hash": "56248c1c1d5039bdf3c1043ade88f3f158ceb52b",
"commit_date": "2025-05-08T18:26:46+00:00",
"build_tree_dirty": true,
"components": [
{"id": "linux", "version": "6.12.15"},
{"id": "kubernetes", "version": "1.32.0"}
]
}
The product info has the same inputs and a similar purpose as the
os-release file, so they are both generated by the same build action.
Change-Id: I89d453f2d72ac9df49e404f46381cd594534f800
Reviewed-on: https://review.monogon.dev/c/monogon/+/4192
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/build/print-workspace-status.py b/build/print-workspace-status.py
index 3b86ee4..34309f3 100755
--- a/build/print-workspace-status.py
+++ b/build/print-workspace-status.py
@@ -63,6 +63,7 @@
if not args.nostamp:
variables["STABLE_MONOGON_gitCommit"] = git_commit
+ variables["STABLE_MONOGON_gitCommitDate"] = git_commit_date
variables["STABLE_MONOGON_gitTreeState"] = git_tree_state
if args.nostamp:
@@ -232,6 +233,20 @@
variables["STABLE_KUBERNETES_gitTreeState"] = ""
variables["STABLE_KUBERNETES_buildDate"] = "1970-01-01T00:00:00Z"
+
+# Collect component versions.
+with open("build/bazel/third_party.MODULE.bazel") as f:
+ third_party_bazel = f.read()
+
+linux_version_result = re.findall(r'^LINUX_VERSION = "([a-zA-Z0-9_.-]+)"$', third_party_bazel, re.MULTILINE)
+if len(linux_version_result) != 1:
+ raise Exception("did not find LINUX_VERSION")
+variables["STABLE_MONOGON_componentVersion_linux"] = linux_version_result[0]
+
+if kubernetes_version[:1] != "v":
+ raise Exception("expected v prefix: " + kubernetes_version)
+variables["STABLE_MONOGON_componentVersion_kubernetes"] = kubernetes_version[1:]
+
# Emit variables to stdout for consumption by Bazel and targets.
for key in sorted(variables.keys()):
print("{} {}".format(key, variables[key]))
diff --git a/metropolis/installer/BUILD.bazel b/metropolis/installer/BUILD.bazel
index cf7beb0..29eb04c 100644
--- a/metropolis/installer/BUILD.bazel
+++ b/metropolis/installer/BUILD.bazel
@@ -1,5 +1,5 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
-load("//osbase/build/genosrelease:defs.bzl", "os_release")
+load("//osbase/build/genproductinfo:defs.bzl", "product_info")
load("//osbase/build/mkcpio:def.bzl", "node_initramfs")
load("//osbase/build/mkpayload:def.bzl", "efi_unified_kernel_image")
@@ -43,10 +43,11 @@
visibility = ["//metropolis/installer/test:__pkg__"],
)
-os_release(
- name = "installer-release-info",
+product_info(
+ name = "product_info",
os_id = "metropolis-installer",
os_name = "Metropolis Installer",
+ out_os_release = ":product_info_os_release",
stamp_var = "STABLE_MONOGON_metropolis_version",
)
@@ -58,6 +59,6 @@
":initramfs",
],
kernel = "//third_party/linux",
- os_release = ":installer-release-info",
+ os_release = ":product_info_os_release",
visibility = ["//visibility:public"],
)
diff --git a/metropolis/node/BUILD.bazel b/metropolis/node/BUILD.bazel
index 04b164a..69425e2 100644
--- a/metropolis/node/BUILD.bazel
+++ b/metropolis/node/BUILD.bazel
@@ -1,6 +1,6 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
load("@rules_pkg//:pkg.bzl", "pkg_zip")
-load("//osbase/build/genosrelease:defs.bzl", "os_release")
+load("//osbase/build/genproductinfo:defs.bzl", "product_info")
load("//osbase/build/mkerofs:def.bzl", "erofs_image")
load("//osbase/build/mkimage:def.bzl", "node_image")
load("//osbase/build/mkoci:def.bzl", "oci_os_image")
@@ -61,7 +61,7 @@
"/etc/passwd": ":passwd",
"/etc/resolv.conf": "//osbase/net/dns:resolv.conf",
"/etc/hosts": "//osbase/net/dns:hosts",
- "/etc/os-release": ":os-release-info",
+ "/etc/os-release": ":product_info_os_release",
# Metrics exporters
"/metrics/bin/node_exporter": "@com_github_prometheus_node_exporter//:node_exporter",
@@ -123,7 +123,7 @@
cmdline = "console=ttyS0,115200 console=ttyS1,115200 console=ttyAMA0 console=tty0 quiet rootfstype=erofs init=/init loadpin.exclude=kexec-image,kexec-initramfs kernel.unknown_nmi_panic=1",
initrd = ["//third_party:ucode"],
kernel = "//third_party/linux",
- os_release = ":os-release-info",
+ os_release = ":product_info_os_release",
verity = ":verity_rootfs",
)
@@ -161,10 +161,15 @@
],
)
-os_release(
- name = "os-release-info",
+product_info(
+ name = "product_info",
+ components = [
+ "linux",
+ "kubernetes",
+ ],
os_id = "metropolis-node",
os_name = "Metropolis Node",
+ out_os_release = ":product_info_os_release",
stamp_var = "STABLE_MONOGON_metropolis_version",
)
diff --git a/osbase/build/genosrelease/BUILD.bazel b/osbase/build/genosrelease/BUILD.bazel
deleted file mode 100644
index f845cae..0000000
--- a/osbase/build/genosrelease/BUILD.bazel
+++ /dev/null
@@ -1,18 +0,0 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
-
-go_library(
- name = "genosrelease_lib",
- srcs = ["main.go"],
- importpath = "source.monogon.dev/osbase/build/genosrelease",
- visibility = ["//visibility:private"],
- deps = ["@com_github_joho_godotenv//:godotenv"],
-)
-
-go_binary(
- name = "genosrelease",
- embed = [":genosrelease_lib"],
- visibility = [
- "//metropolis/installer:__subpackages__",
- "//metropolis/node:__subpackages__",
- ],
-)
diff --git a/osbase/build/genosrelease/defs.bzl b/osbase/build/genosrelease/defs.bzl
deleted file mode 100644
index 2e7c613..0000000
--- a/osbase/build/genosrelease/defs.bzl
+++ /dev/null
@@ -1,54 +0,0 @@
-# Copyright 2020 The Monogon Project Authors.
-#
-# SPDX-License-Identifier: Apache-2.0
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-def _os_release_impl(ctx):
- ctx.actions.run(
- mnemonic = "GenOSRelease",
- progress_message = "Generating os-release",
- inputs = [ctx.info_file],
- outputs = [ctx.outputs.out],
- executable = ctx.executable._genosrelease,
- arguments = [
- "-status_file",
- ctx.info_file.path,
- "-out_file",
- ctx.outputs.out.path,
- "-stamp_var",
- ctx.attr.stamp_var,
- "-name",
- ctx.attr.os_name,
- "-id",
- ctx.attr.os_id,
- ],
- )
-
-os_release = rule(
- implementation = _os_release_impl,
- attrs = {
- "os_name": attr.string(mandatory = True),
- "os_id": attr.string(mandatory = True),
- "stamp_var": attr.string(mandatory = True),
- "_genosrelease": attr.label(
- default = Label("//osbase/build/genosrelease"),
- cfg = "exec",
- executable = True,
- allow_files = True,
- ),
- },
- outputs = {
- "out": "os-release",
- },
-)
diff --git a/osbase/build/genosrelease/main.go b/osbase/build/genosrelease/main.go
deleted file mode 100644
index 8d061f9..0000000
--- a/osbase/build/genosrelease/main.go
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright The Monogon Project Authors.
-// SPDX-License-Identifier: Apache-2.0
-
-// genosrelease provides rudimentary support to generate os-release files
-// following the freedesktop spec from arguments and stamping
-//
-// https://www.freedesktop.org/software/systemd/man/os-release.html
-package main
-
-import (
- "flag"
- "fmt"
- "os"
- "strings"
-
- "github.com/joho/godotenv"
-)
-
-var (
- flagStatusFile = flag.String("status_file", "", "path to bazel workspace status file")
- flagOutFile = flag.String("out_file", "os-release", "path to os-release output file")
- flagStampVar = flag.String("stamp_var", "", "variable to use as version from the workspace status file")
- flagName = flag.String("name", "", "name parameter (see freedesktop spec)")
- flagID = flag.String("id", "", "id parameter (see freedesktop spec)")
-)
-
-func main() {
- flag.Parse()
- statusFileContent, err := os.ReadFile(*flagStatusFile)
- if err != nil {
- fmt.Printf("Failed to open bazel workspace status file: %v\n", err)
- os.Exit(1)
- }
- statusVars := make(map[string]string)
- for _, line := range strings.Split(string(statusFileContent), "\n") {
- line = strings.TrimSpace(line)
- parts := strings.Fields(line)
- if len(parts) != 2 {
- continue
- }
- statusVars[parts[0]] = parts[1]
- }
-
- version, ok := statusVars[*flagStampVar]
- if !ok {
- fmt.Printf("%v key not set in bazel workspace status file\n", *flagStampVar)
- os.Exit(1)
- }
- // As specified by https://www.freedesktop.org/software/systemd/man/os-release.html
- osReleaseVars := map[string]string{
- "NAME": *flagName,
- "ID": *flagID,
- "VERSION": version,
- "VERSION_ID": version,
- "PRETTY_NAME": *flagName + " " + version,
- }
- osReleaseContent, err := godotenv.Marshal(osReleaseVars)
- if err != nil {
- fmt.Printf("Failed to encode os-release file: %v\n", err)
- os.Exit(1)
- }
- if err := os.WriteFile(*flagOutFile, []byte(osReleaseContent+"\n"), 0644); err != nil {
- fmt.Printf("Failed to write os-release file: %v\n", err)
- os.Exit(1)
- }
-}
diff --git a/osbase/build/genproductinfo/BUILD.bazel b/osbase/build/genproductinfo/BUILD.bazel
new file mode 100644
index 0000000..036d448
--- /dev/null
+++ b/osbase/build/genproductinfo/BUILD.bazel
@@ -0,0 +1,28 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+ name = "genproductinfo_lib",
+ srcs = ["main.go"],
+ importpath = "source.monogon.dev/osbase/build/genproductinfo",
+ visibility = ["//visibility:private"],
+ deps = [
+ "//osbase/oci/osimage",
+ "@com_github_joho_godotenv//:godotenv",
+ ],
+)
+
+go_binary(
+ name = "genproductinfo",
+ embed = [":genproductinfo_lib"],
+ visibility = ["//visibility:public"],
+)
+
+config_setting(
+ name = "flag_debug",
+ values = {"compilation_mode": "dbg"},
+)
+
+config_setting(
+ name = "flag_race",
+ flag_values = {"@io_bazel_rules_go//go/config:race": "True"},
+)
diff --git a/osbase/build/genproductinfo/defs.bzl b/osbase/build/genproductinfo/defs.bzl
new file mode 100644
index 0000000..c484bbb
--- /dev/null
+++ b/osbase/build/genproductinfo/defs.bzl
@@ -0,0 +1,68 @@
+# Copyright The Monogon Project Authors.
+# SPDX-License-Identifier: Apache-2.0
+
+def _product_info_impl(ctx):
+ product_info_file = ctx.actions.declare_file(ctx.label.name + ".json")
+ args = ctx.actions.args()
+ args.add("-status_file", ctx.info_file)
+ args.add("-product_info_file", product_info_file)
+ args.add("-os_release_file", ctx.outputs.out_os_release)
+ args.add("-stamp_var", ctx.attr.stamp_var)
+ args.add("-name", ctx.attr.os_name)
+ args.add("-id", ctx.attr.os_id)
+ args.add("-architecture", ctx.attr.architecture)
+ args.add("-build_flags", "-".join(ctx.attr.build_flags))
+ args.add_all(ctx.attr.components, before_each = "-component")
+ ctx.actions.run(
+ mnemonic = "GenProductInfo",
+ progress_message = "Generating product info",
+ inputs = [ctx.info_file],
+ outputs = [product_info_file, ctx.outputs.out_os_release],
+ executable = ctx.executable._genproductinfo,
+ arguments = [args],
+ )
+ return [DefaultInfo(files = depset([product_info_file]))]
+
+_product_info = rule(
+ implementation = _product_info_impl,
+ attrs = {
+ "os_name": attr.string(mandatory = True),
+ "os_id": attr.string(mandatory = True),
+ "stamp_var": attr.string(mandatory = True),
+ "components": attr.string_list(),
+ "out_os_release": attr.output(
+ mandatory = True,
+ doc = """Output, contains the os-release file.""",
+ ),
+ "architecture": attr.string(mandatory = True),
+ "build_flags": attr.string_list(),
+ "_genproductinfo": attr.label(
+ default = ":genproductinfo",
+ cfg = "exec",
+ executable = True,
+ allow_files = True,
+ ),
+ },
+)
+
+def _product_info_macro_impl(**kwargs):
+ _product_info(
+ architecture = select({
+ "@platforms//cpu:x86_64": "x86_64",
+ "@platforms//cpu:aarch64": "aarch64",
+ }),
+ build_flags = select({
+ Label(":flag_debug"): ["debug"],
+ "//conditions:default": [],
+ }) + select({
+ Label(":flag_race"): ["race"],
+ "//conditions:default": [],
+ }),
+ **kwargs
+ )
+
+product_info = macro(
+ inherit_attrs = _product_info,
+ attrs = {"architecture": None, "build_flags": None},
+ implementation = _product_info_macro_impl,
+)
diff --git a/osbase/build/genproductinfo/main.go b/osbase/build/genproductinfo/main.go
new file mode 100644
index 0000000..e4b566d
--- /dev/null
+++ b/osbase/build/genproductinfo/main.go
@@ -0,0 +1,146 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+// genproductinfo generates a product info JSON file from arguments and
+// stamping. Additionally, it generates an os-release file following the
+// freedesktop spec from the same information.
+//
+// https://www.freedesktop.org/software/systemd/man/os-release.html
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/joho/godotenv"
+
+ "source.monogon.dev/osbase/oci/osimage"
+)
+
+var (
+ flagStatusFile = flag.String("status_file", "", "path to bazel workspace status file")
+ flagProductInfoFile = flag.String("product_info_file", "product-info", "path to product info output file")
+ flagOSReleaseFile = flag.String("os_release_file", "os-release", "path to os-release output file")
+ flagStampVar = flag.String("stamp_var", "", "variable to use as version from the workspace status file")
+ flagName = flag.String("name", "", "name parameter (see freedesktop spec)")
+ flagID = flag.String("id", "", "id parameter (see freedesktop spec)")
+ flagArchitecture = flag.String("architecture", "", "CPU architecture")
+ flagBuildFlags = flag.String("build_flags", "", "build flags joined by '-'")
+)
+
+var (
+ rePrereleaseGitHash = regexp.MustCompile(`^g[0-9a-f]+$`)
+)
+
+func versionWithoutGitInfo(version string) string {
+ version, metadata, hasMetadata := strings.Cut(version, "+")
+ version, prerelease, hasPrerelease := strings.Cut(version, "-")
+ if hasPrerelease {
+ var filteredParts []string
+ for part := range strings.SplitSeq(prerelease, ".") {
+ switch {
+ case part == "dirty":
+ // Ignore field.
+ case rePrereleaseGitHash.FindStringSubmatch(part) != nil:
+ // Ignore field.
+ default:
+ filteredParts = append(filteredParts, part)
+ }
+ }
+ if len(filteredParts) != 0 {
+ version = version + "-" + strings.Join(filteredParts, ".")
+ }
+ }
+ if hasMetadata {
+ version = version + "+" + metadata
+ }
+ return version
+}
+
+func main() {
+ var componentIDs []string
+ flag.Func("component", "ID of a component", func(component string) error {
+ componentIDs = append(componentIDs, component)
+ return nil
+ })
+ flag.Parse()
+
+ statusFileContent, err := os.ReadFile(*flagStatusFile)
+ if err != nil {
+ log.Fatalf("Failed to open bazel workspace status file: %v", err)
+ }
+ statusVars := make(map[string]string)
+ for line := range strings.SplitSeq(string(statusFileContent), "\n") {
+ if line == "" {
+ continue
+ }
+ key, value, ok := strings.Cut(line, " ")
+ if !ok {
+ log.Fatalf("Invalid line in status file: %q", line)
+ }
+ statusVars[key] = value
+ }
+
+ version, ok := statusVars[*flagStampVar]
+ if !ok {
+ log.Fatalf("%s key not set in bazel workspace status file", *flagStampVar)
+ }
+
+ var components []osimage.Component
+ for _, id := range componentIDs {
+ versionKey := fmt.Sprintf("STABLE_MONOGON_componentVersion_%s", id)
+ version, ok := statusVars[versionKey]
+ if !ok {
+ log.Fatalf("%s key not set in bazel workspace status file", versionKey)
+ }
+ components = append(components, osimage.Component{
+ ID: id,
+ Version: version,
+ })
+ }
+
+ variant := *flagArchitecture
+ if *flagBuildFlags != "" {
+ variant = variant + "-" + *flagBuildFlags
+ }
+
+ productInfo := osimage.ProductInfo{
+ ID: *flagID,
+ Name: *flagName,
+ Version: versionWithoutGitInfo(version),
+ Variant: variant,
+ CommitHash: statusVars["STABLE_MONOGON_gitCommit"],
+ CommitDate: statusVars["STABLE_MONOGON_gitCommitDate"],
+ BuildTreeDirty: statusVars["STABLE_MONOGON_gitTreeState"] == "dirty",
+ Components: components,
+ }
+ productInfoBytes, err := json.MarshalIndent(productInfo, "", "\t")
+ if err != nil {
+ log.Fatalf("Failed to marshal OS image config: %v", err)
+ }
+ productInfoBytes = append(productInfoBytes, '\n')
+ if err := os.WriteFile(*flagProductInfoFile, productInfoBytes, 0644); err != nil {
+ log.Fatalf("Failed to write product info file: %v", err)
+ }
+
+ // As specified by https://www.freedesktop.org/software/systemd/man/os-release.html
+ osReleaseVars := map[string]string{
+ "NAME": *flagName,
+ "ID": *flagID,
+ "VERSION": version,
+ "VERSION_ID": version,
+ "PRETTY_NAME": *flagName + " " + version,
+ }
+ osReleaseContent, err := godotenv.Marshal(osReleaseVars)
+ if err != nil {
+ log.Fatalf("Failed to encode os-release file: %v", err)
+ }
+ if err := os.WriteFile(*flagOSReleaseFile, []byte(osReleaseContent+"\n"), 0644); err != nil {
+ log.Fatalf("Failed to write os-release file: %v", err)
+ }
+}
diff --git a/osbase/oci/osimage/types.go b/osbase/oci/osimage/types.go
index d81d759..d251a74 100644
--- a/osbase/oci/osimage/types.go
+++ b/osbase/oci/osimage/types.go
@@ -3,6 +3,8 @@
package osimage
+import "strings"
+
// Media types which appear in the OCI image manifest.
const (
ArtifactTypeOSImage = "application/vnd.monogon.os.image.v1"
@@ -24,6 +26,60 @@
Payloads []PayloadInfo `json:"payloads"`
}
+type ProductInfo struct {
+ // ID of the product in the image. Recommended to be the same as the ID
+ // property in os-release, and should follow the same syntax restrictions.
+ // Example: "metropolis-node"
+ // See: https://www.freedesktop.org/software/systemd/man/latest/os-release.html#ID=
+ ID string `json:"id"`
+ // Name of the product in the image. Recommended to be the same as the NAME
+ // property in os-release. Example: "Metropolis Node"
+ Name string `json:"name"`
+ // Version of the product in the image.
+ Version string `json:"version"`
+ // Variant of the product build. This contains the values of relevant flags
+ // passed to the build command. Currently, this is the architecture, and the
+ // debug and race flag if set. Additional flags may be added in the future.
+ // Examples: "x86_64-debug", "aarch64"
+ //
+ // The first "-"-separated component will always be the architecture.
+ // See //build/platforms/BUILD.bazel for available architectures.
+ //
+ // This must contain only characters in the set [a-zA-Z0-9._-], such that it
+ // can be part of a tag in an OCI registry.
+ Variant string `json:"variant"`
+
+ // CommitHash is the hex-encoded, full hash of the commit from which the image
+ // was built.
+ CommitHash string `json:"commit_hash"`
+ // CommitDate of the commit from which the image was built.
+ // This gives an indication of how old the image is, as it does not contain
+ // changes made after this date.
+ CommitDate string `json:"commit_date"`
+ // BuildTreeDirty indicates that the tree from which the image was built
+ // differs from the tree of the commit referenced by commit_hash.
+ BuildTreeDirty bool `json:"build_tree_dirty"`
+
+ // Components contains versions of the most important components. These are
+ // mostly intended for human consumption, but could also be used for certain
+ // automations, e.g. automatically deriving Kubernetes compatibility
+ // constraints.
+ Components []Component `json:"components,omitzero"`
+}
+
+// Architecture returns the CPU architecture, extracted from Variant.
+func (p *ProductInfo) Architecture() string {
+ architecture, _, _ := strings.Cut(p.Variant, "-")
+ return architecture
+}
+
+type Component struct {
+ // ID of the component. Example: "linux"
+ ID string `json:"id"`
+ // Version of the component. Example: "6.6.50"
+ Version string `json:"version"`
+}
+
type PayloadInfo struct {
// Name of this payload, for example "system" or "kernel.efi". Must consist of
// at least one and at most 80 characters in the set [0-9A-Za-z._-], and may