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/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)
+ }
+}