]> go.fuhry.dev Git - runtime.git/commitdiff
[pkg] add nfpm package generation
authorDan Fuhry <dan@fuhry.com>
Sun, 15 Mar 2026 00:12:09 +0000 (20:12 -0400)
committerDan Fuhry <dan@fuhry.com>
Sun, 15 Mar 2026 01:19:22 +0000 (21:19 -0400)
- add nfpm_package bazel helper
- add nfpm config generator for discovering executables and systemd units added as bazel deps
- add packages for:
  - apcupsd-exporter
  - http-proxy
  - ldap-health-exporter
  - prometheus-helpers
  - runtime-utils

bazel/pkg.bzl [new file with mode: 0644]
pkg/BUILD.bazel [new file with mode: 0644]
pkg/apcupsd-exporter.yaml [new file with mode: 0644]
pkg/http-proxy.yaml [new file with mode: 0644]
pkg/ldap-health-exporter.yaml [new file with mode: 0644]
pkg/nfpmgen/BUILD.bazel [new file with mode: 0644]
pkg/nfpmgen/config_generator.go [new file with mode: 0644]
pkg/nfpmgen/main.go [new file with mode: 0644]
pkg/prometheus-helpers.yaml [new file with mode: 0644]
pkg/runtime-utils.yaml [new file with mode: 0644]
thirdparty/go-version/0001-Never-return-nil-from-getBuildInfo.patch [new file with mode: 0644]

diff --git a/bazel/pkg.bzl b/bazel/pkg.bzl
new file mode 100644 (file)
index 0000000..5144701
--- /dev/null
@@ -0,0 +1,70 @@
+def _nfpm_package(
+    package_name,
+    config,
+    data,
+    packager,
+):
+    extension_map = {
+        "archlinux": "pkg.tar.zst",
+    }
+    distro_vars = {
+        "archlinux": {
+            "ENV_DIR": "/etc/conf.d",
+        },
+        "deb": {
+            "ENV_DIR": "/etc/default",
+        },
+        "rpm": {
+            "ENV_DIR": "/etc/default",
+        },
+    }
+    pkg_prefix = "{}_{}".format(package_name.replace("-", "_"), packager)
+    extra_args = []
+    for target in data:
+        extra_args.append('--include="$(location {})"'.format(target))
+
+    for var, val in distro_vars.get(packager, {}).items():
+        extra_args.append('--var="{}={}"'.format(var, val))
+
+    native.genrule(
+        name = "{}_nfpm_config".format(pkg_prefix),
+        tools = [
+            "//pkg/nfpmgen",
+        ],
+        srcs = [config] + data,
+        outs = [
+            "{}_nfpm_config.yaml".format(pkg_prefix),
+        ],
+        cmd_bash = """
+        $(location //pkg/nfpmgen) --in="$(location {})" --out="$@" {}
+        """.format(config, " ".join(extra_args)),
+    )
+
+    native.genrule(
+        name = "{}_nfpm_pkg".format(pkg_prefix),
+        tools = [
+            "@com_github_goreleaser_nfpm_v2//cmd/nfpm:nfpm",
+        ],
+        srcs = [
+            ":{}_nfpm_config".format(pkg_prefix),
+        ] + data,
+        outs = [
+            "{}.{}".format(package_name, extension_map.get(packager, packager)),
+        ],
+        cmd = " ".join([
+            "$(location @com_github_goreleaser_nfpm_v2//cmd/nfpm:nfpm)",
+            "package",
+            "-p {}".format(packager),
+            "-f $(location :{}_nfpm_config)".format(pkg_prefix),
+            "-t $@",
+        ]),
+    )
+
+def nfpm_package(
+    name,  # type: str
+    config,  # type: str
+    data,  # type: list[str]
+):
+    #for packager in ["deb", "archlinux", "rpm"]:
+    for packager in ["archlinux"]:
+        _nfpm_package(name, config, data, packager)
\ No newline at end of file
diff --git a/pkg/BUILD.bazel b/pkg/BUILD.bazel
new file mode 100644 (file)
index 0000000..a171e56
--- /dev/null
@@ -0,0 +1,58 @@
+load("//bazel:pkg.bzl", "nfpm_package")
+
+nfpm_package(
+    name = "runtime-utils",
+    config = ":runtime-utils.yaml",
+    data = [
+        "//cmd/ephs_client",
+        "//cmd/metricbus_server",
+        "//cmd/metricbus_server:metric_collector_systemd_service",
+        "//cmd/mtls_exporter",
+        "//cmd/mtls_exporter:mtls_exporter_systemd_service",
+        "//cmd/mtls_supervisor",
+        "//cmd/sd_health_exporter",
+        "//cmd/sd_health_exporter:sd_health_exporter_systemd_service",
+        "//cmd/sd_publish",
+        "//cmd/sd_register",
+        "//cmd/sd_register:sd_register@_systemd_service",
+        "//cmd/sd_register:sd_register_systemd_service",
+        "//cmd/sd_watcher",
+    ],
+)
+
+nfpm_package(
+    name = "apcupsd-exporter",
+    config = ":apcupsd-exporter.yaml",
+    data = [
+        "//cmd/apcups_exporter",
+        "//cmd/apcups_exporter:apcupsd_exporter@_systemd_service",
+        "//cmd/apcups_exporter:apcupsd_exporter_systemd_service",
+    ],
+)
+
+nfpm_package(
+    name = "http-proxy",
+    config = ":http-proxy.yaml",
+    data = [
+        "//cmd/http_proxy",
+        "//cmd/http_proxy:http_proxy@_systemd_service",
+    ],
+)
+
+nfpm_package(
+    name = "ldap-health-exporter",
+    config = ":ldap-health-exporter.yaml",
+    data = [
+        "//cmd/ldap_health_exporter",
+        "//cmd/ldap_health_exporter:ldap_health_exporter@_systemd_service",
+    ],
+)
+
+nfpm_package(
+    name = "prometheus-helpers",
+    config = ":prometheus-helpers.yaml",
+    data = [
+        "//cmd/prometheus_http_discovery",
+        "//cmd/prometheus_http_discovery:prometheus_http_discovery_systemd_service",
+    ],
+)
diff --git a/pkg/apcupsd-exporter.yaml b/pkg/apcupsd-exporter.yaml
new file mode 100644 (file)
index 0000000..b6b620c
--- /dev/null
@@ -0,0 +1,15 @@
+name: "${const:ExePrefix}apcupsd-exporter"
+arch: "amd64"
+platform: "linux"
+version: "${sub:${const:Version},+unset,}"
+section: "net"
+priority: "extra"
+maintainer: "Bazel Build Service <bazelbuild@${const:RootDomain}>"
+depends:
+  - ${const:ExePrefix}runtime-utils
+description: |
+  no description provided
+vendor: "${const:OrgName}"
+homepage: "https://go.fuhry.dev/runtime"
+license: "Proprietary"
+contents: []
\ No newline at end of file
diff --git a/pkg/http-proxy.yaml b/pkg/http-proxy.yaml
new file mode 100644 (file)
index 0000000..3b83f6f
--- /dev/null
@@ -0,0 +1,15 @@
+name: "${const:ExePrefix}http-proxy"
+arch: "amd64"
+platform: "linux"
+version: "${sub:${const:Version},+unset,}"
+section: "net"
+priority: "extra"
+maintainer: "Bazel Build Service <bazelbuild@${const:RootDomain}>"
+depends:
+  - ${const:ExePrefix}runtime-utils
+description: |
+  no description provided
+vendor: "${const:OrgName}"
+homepage: "https://go.fuhry.dev/runtime"
+license: "Proprietary"
+contents: []
\ No newline at end of file
diff --git a/pkg/ldap-health-exporter.yaml b/pkg/ldap-health-exporter.yaml
new file mode 100644 (file)
index 0000000..e7013d2
--- /dev/null
@@ -0,0 +1,15 @@
+name: "${const:ExePrefix}ldap-health-exporter"
+arch: "amd64"
+platform: "linux"
+version: "${sub:${const:Version},+unset,}"
+section: "net"
+priority: "extra"
+maintainer: "Bazel Build Service <bazelbuild@${const:RootDomain}>"
+depends:
+  - ${const:ExePrefix}runtime-utils
+description: |
+  no description provided
+vendor: "${const:OrgName}"
+homepage: "https://go.fuhry.dev/runtime"
+license: "Proprietary"
+contents: []
\ No newline at end of file
diff --git a/pkg/nfpmgen/BUILD.bazel b/pkg/nfpmgen/BUILD.bazel
new file mode 100644 (file)
index 0000000..6260654
--- /dev/null
@@ -0,0 +1,24 @@
+load("@rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "nfpmgen_lib",
+    srcs = [
+        "config_generator.go",
+        "main.go",
+    ],
+    importpath = "go.fuhry.dev/runtime/pkg/nfpmgen",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//constants",
+        "//utils/subst",
+        "@com_github_goreleaser_nfpm_v2//:nfpm",
+        "@com_github_goreleaser_nfpm_v2//files",
+        "@in_gopkg_yaml_v3//:yaml_v3",
+    ],
+)
+
+go_binary(
+    name = "nfpmgen",
+    embed = [":nfpmgen_lib"],
+    visibility = ["//visibility:public"],
+)
diff --git a/pkg/nfpmgen/config_generator.go b/pkg/nfpmgen/config_generator.go
new file mode 100644 (file)
index 0000000..d91fc64
--- /dev/null
@@ -0,0 +1,249 @@
+package main
+
+import (
+       "flag"
+       "fmt"
+       "os"
+       "path"
+       "regexp"
+       "strings"
+
+       "github.com/goreleaser/nfpm/v2"
+       "github.com/goreleaser/nfpm/v2/files"
+       "gopkg.in/yaml.v3"
+
+       "go.fuhry.dev/runtime/constants"
+       "go.fuhry.dev/runtime/utils/subst"
+)
+
+var verbose bool
+
+func debug(fmtstr string, args ...any) {
+       if !verbose {
+               return
+       }
+
+       fmt.Fprintf(os.Stderr, fmtstr+"\n", args...)
+}
+
+type NfpmConfig struct {
+       *nfpm.Config
+
+       Vars subst.KV `yaml:"vars" json:"vars" jsonschema:"title=map of context vars to inject into subst"`
+
+       files map[string]struct{} `yaml:"-" json:"-"`
+}
+
+type executable string
+
+var systemdUnitFilename = regexp.MustCompile(`\.(service|target|socket|preset)$`)
+
+func (e executable) String() string {
+       return string(e)
+}
+
+func (e executable) buildPath(bazelBinDir string) string {
+       return path.Join(
+               bazelBinDir, "cmd", e.String(), e.String()+"_", e.String())
+}
+
+func (e executable) pkgPath(vars subst.KV) string {
+       exeDir, ok := vars["EXEDIR"]
+       if !ok {
+               exeDir = "/usr/bin"
+       }
+
+       exeName := constants.ExePrefix + strings.ReplaceAll(string(e), "_", "-")
+
+       return path.Join(exeDir, exeName)
+}
+
+func LoadNfpmConfig(yamlPath string, fileAllowlist []string, varOverrides subst.KV) (*NfpmConfig, error) {
+       contents, err := os.ReadFile(yamlPath)
+       if err != nil {
+               return nil, err
+       }
+
+       // first load the "vars" key to substitute throughout the rest of the file
+       out := &NfpmConfig{
+               Config: new(nfpm.Config),
+               Vars:   make(subst.KV),
+
+               files: make(map[string]struct{}),
+       }
+
+       for _, f := range fileAllowlist {
+               out.files[f] = struct{}{}
+       }
+
+       if err := yaml.Unmarshal(contents, out); err != nil {
+               return nil, err
+       }
+
+       for k, v := range varOverrides {
+               debug("var %q overridden to %q via command line", k, v)
+               out.Vars[k] = v
+       }
+
+       substContents, err := subst.Eval(out.Vars, string(contents))
+       if err != nil {
+               return nil, err
+       }
+
+       if err := yaml.Unmarshal([]byte(substContents), out.Config); err != nil {
+               return nil, err
+       }
+
+       return out, nil
+}
+
+func (c *NfpmConfig) DiscoverBinaries(bazelBinDir string) error {
+       wd, err := os.Getwd()
+       if err != nil {
+               return err
+       }
+
+       cmdDir, err := os.ReadDir(path.Join(wd, bazelBinDir, "cmd"))
+       if err != nil {
+               return err
+       }
+
+       for _, entry := range cmdDir {
+               if entry.Name() == "." || entry.Name() == ".." || !entry.IsDir() {
+                       continue
+               }
+
+               exe := executable(entry.Name())
+
+               exePath := exe.buildPath(bazelBinDir)
+
+               if _, ok := c.files[exePath]; !ok {
+                       debug("exe not allowlisted in includes: %s", exePath)
+                       continue
+               }
+
+               debug("evaluating exe with buildPath %s", exePath)
+
+               if derefPath, err := os.Readlink(exePath); err == nil {
+                       if path.IsAbs(derefPath) {
+                               exePath = derefPath
+                       } else {
+                               exePath = path.Join(
+                                       path.Dir(exePath),
+                                       derefPath,
+                               )
+                       }
+
+                       debug("  -> dereferenced symlink to: %s", exePath)
+               }
+
+               st, err := os.Stat(exePath)
+               if err != nil {
+                       debug("  -> executable not found")
+                       continue
+               }
+
+               if st.Mode()&os.FileMode(0o500) != 0o500 || !st.Mode().IsRegular() {
+                       debug("  -> not a regular file or not executable")
+                       continue
+               }
+
+               c.Config.Contents = append(c.Config.Contents, &files.Content{
+                       Source:      exePath,
+                       Destination: exe.pkgPath(c.Vars),
+                       FileInfo: &files.ContentFileInfo{
+                               Owner: "root",
+                               Group: "root",
+                               Mode:  os.FileMode(0o755),
+                       },
+               })
+       }
+
+       return nil
+}
+
+func recursiveDirectoryScan(baseDir string, match func(e os.DirEntry) bool) ([]string, error) {
+       var out []string
+       entries, err := os.ReadDir(baseDir)
+       if err != nil {
+               return nil, err
+       }
+
+       for _, e := range entries {
+               abs := path.Join(baseDir, e.Name())
+               if match(e) {
+                       out = append(out, abs)
+               }
+
+               if e.IsDir() {
+                       children, err := recursiveDirectoryScan(abs, match)
+                       if err != nil {
+                               return nil, err
+                       }
+                       out = append(out, children...)
+               }
+       }
+
+       return out, nil
+}
+
+func (c *NfpmConfig) DiscoverSystemdUnits(bazelBinDir string) error {
+       unitFiles, err := recursiveDirectoryScan(
+               bazelBinDir,
+               func(e os.DirEntry) bool {
+                       if e.IsDir() {
+                               return false
+                       }
+
+                       return systemdUnitFilename.MatchString(e.Name())
+               },
+       )
+       if err != nil {
+               return err
+       }
+
+       for _, f := range unitFiles {
+               if _, ok := c.files[f]; !ok {
+                       debug("unit file not allowlisted in includes: %s", f)
+                       continue
+               }
+
+               if derefPath, err := os.Readlink(f); err == nil {
+                       if path.IsAbs(derefPath) {
+                               f = derefPath
+                       } else {
+                               f = path.Join(
+                                       path.Dir(f),
+                                       derefPath,
+                               )
+                       }
+               }
+
+               destName := constants.ExePrefix + path.Base(f)
+
+               c.Config.Contents = append(c.Config.Contents, &files.Content{
+                       Source:      f,
+                       Destination: "/usr/lib/systemd/" + destName,
+                       FileInfo: &files.ContentFileInfo{
+                               Owner: "root",
+                               Group: "root",
+                               Mode:  os.FileMode(0o644),
+                       },
+               })
+       }
+
+       return nil
+}
+
+func (c *NfpmConfig) Generate() (string, error) {
+       out, err := yaml.Marshal(c.Config)
+       if err != nil {
+               return "", err
+       }
+
+       return string(out), nil
+}
+
+func init() {
+       flag.BoolVar(&verbose, "verbose", false, "enable debug logging")
+}
diff --git a/pkg/nfpmgen/main.go b/pkg/nfpmgen/main.go
new file mode 100644 (file)
index 0000000..6fa7be7
--- /dev/null
@@ -0,0 +1,89 @@
+package main
+
+import (
+       "errors"
+       "flag"
+       "fmt"
+       "os"
+       "strings"
+
+       "go.fuhry.dev/runtime/utils/subst"
+)
+
+func main() {
+       yamlFile := flag.String("in", "", "path to yaml template to consume")
+       outFile := flag.String("out", "", "output file to write; if omitted, uses stdout")
+
+       var includes []string
+       parseInclude := func(flagVal string) error {
+               _, err := os.Lstat(flagVal)
+               if err != nil {
+                       return fmt.Errorf("error processing include %q: %v", flagVal, err)
+               }
+               includes = append(includes, flagVal)
+
+               return nil
+       }
+       flag.Func("include", "file to consider for package", parseInclude)
+
+       kv := make(subst.KV)
+       addKv := func(flagVal string) error {
+               k, v, ok := strings.Cut(flagVal, "=")
+               if !ok {
+                       return errors.New("-var= usage: var=value, e.g. -var=FOO=BAR")
+               }
+               kv[k] = v
+               return nil
+       }
+       flag.Func("var", "extra variable to set", addKv)
+
+       flag.Parse()
+
+       c, err := LoadNfpmConfig(*yamlFile, includes, kv)
+       if err != nil {
+               panic(err)
+       }
+
+       wd, err := os.Getwd()
+       if err != nil {
+               panic(err)
+       }
+
+       bazelBinDir := ""
+       if outFile != nil && *outFile != "" {
+               // pushd to the bazel-bin dir `bazel-bin/[arch]/bin` directory
+               parts := strings.Split(*outFile, "/")
+               if len(parts) >= 3 {
+                       bazelBinDir = strings.Join(parts[:3], "/")
+               }
+       }
+
+       debug("loaded nfpm config template: %s\ncwd: %s\nout: %s\n",
+               *yamlFile, wd, *outFile)
+
+       if err := c.DiscoverBinaries(bazelBinDir); err != nil {
+               panic(err)
+       }
+
+       if err := c.DiscoverSystemdUnits(bazelBinDir); err != nil {
+               panic(err)
+       }
+
+       out, err := c.Generate()
+       if err != nil {
+               panic(err)
+       }
+
+       err = os.Chdir(wd)
+       if err != nil {
+               panic(err)
+       }
+
+       if outFile == nil || *outFile == "" {
+               fmt.Println(out)
+       } else {
+               if err := os.WriteFile(*outFile, []byte(out), os.FileMode(0o644)); err != nil {
+                       panic(err)
+               }
+       }
+}
diff --git a/pkg/prometheus-helpers.yaml b/pkg/prometheus-helpers.yaml
new file mode 100644 (file)
index 0000000..de239c7
--- /dev/null
@@ -0,0 +1,15 @@
+name: "${const:ExePrefix}prometheus-helpers"
+arch: "amd64"
+platform: "linux"
+version: "${sub:${const:Version},+unset,}"
+section: "net"
+priority: "extra"
+maintainer: "Bazel Build Service <bazelbuild@${const:RootDomain}>"
+depends:
+  - ${const:ExePrefix}runtime-utils
+description: |
+  no description provided
+vendor: "${const:OrgName}"
+homepage: "https://go.fuhry.dev/runtime"
+license: "Proprietary"
+contents: []
\ No newline at end of file
diff --git a/pkg/runtime-utils.yaml b/pkg/runtime-utils.yaml
new file mode 100644 (file)
index 0000000..6e87438
--- /dev/null
@@ -0,0 +1,18 @@
+name: "${const:ExePrefix}runtime-utils"
+arch: "amd64"
+platform: "linux"
+version: "${sub:${const:Version},+unset,}"
+section: "net"
+priority: "extra"
+replaces:
+  - ${const:OrgSlug}-service-discovery
+depends:
+  - glibc
+conflicts:
+  - ${const:OrgSlug}-service-discovery
+maintainer: "Bazel Build Service <bazelbuild@${const:RootDomain}>"
+description: Tools needed by virtually all ${const:OrgName} systems for service discovery and metrics
+vendor: "${const:OrgName}"
+homepage: "https://go.fuhry.dev/runtime"
+license: "Proprietary"
+contents: []
\ No newline at end of file
diff --git a/thirdparty/go-version/0001-Never-return-nil-from-getBuildInfo.patch b/thirdparty/go-version/0001-Never-return-nil-from-getBuildInfo.patch
new file mode 100644 (file)
index 0000000..eaf5f63
--- /dev/null
@@ -0,0 +1,25 @@
+From 0edcc9ab033623894d59b9f301a45dd95b322ddd Mon Sep 17 00:00:00 2001
+From: Dan Fuhry <dan@fuhry.com>
+Date: Tue, 20 Jan 2026 21:39:00 -0500
+Subject: [PATCH] Never return nil from getBuildInfo
+
+---
+ version.go | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/version.go b/version.go
+index 5734d52..2a153e7 100644
+--- a/version.go
++++ b/version.go
+@@ -36,7 +36,7 @@ type Info struct {
+ func getBuildInfo() *debug.BuildInfo {
+       bi, ok := debug.ReadBuildInfo()
+       if !ok {
+-              return nil
++              return &debug.BuildInfo{}
+       }
+       return bi
+ }
+-- 
+2.52.0
+