diff --git a/ops/gmpctl.sh b/ops/gmpctl.sh
index 794a7a0828..1c822e4c0b 100755
--- a/ops/gmpctl.sh
+++ b/ops/gmpctl.sh
@@ -26,6 +26,14 @@ SCRIPT_DIR="$(
pwd -P
)"
+# Install dependencies in the current context. We switch Go toolchain versions so using go tool might not work.
+go install github.com/google/go-containerregistry/cmd/gcrane@latest
+go install github.com/mikefarah/yq/v4@latest
+go install helm.sh/helm/v3/cmd/helm@latest
+go install github.com/google/addlicense@latest
+
+export PATH="$(go env GOPATH)/bin:${PATH}"
+
# NOTE gmpctl expects the gmpctl directory to be present on execution for
# local bash scripts and configuration.
#
diff --git a/ops/gmpctl/README.md b/ops/gmpctl/README.md
index 63dc69e149..ee42cc4d81 100644
--- a/ops/gmpctl/README.md
+++ b/ops/gmpctl/README.md
@@ -47,7 +47,7 @@ key information and confirmations e.g.
same parameters, and it will continue the previous work or at least yield same results. This is crucial when iterating
on breaking go mod updates for vulnerabilities or fork sync conflicts.
-```text mdox-exec="bash ops/gmpctl.sh --help"
+```text mdox-exec="bash -c \"bash ops/gmpctl.sh --help 2>&1 | sed -n '/Usage/,$p'\""
Usage: gmpctl [COMMAND] [FLAGS]
-c string
Path to the configuration file. See config.go#Config for the structure. (default ".gmpctl.default.yaml")
@@ -67,6 +67,8 @@ Usage: gmpctl [COMMAND] [FLAGS]
[vulnfix] Usage of vulnfix:
-b string
Release branch to work on; Project is auto-detected from this
+ -go-version string
+ Go minor version to use for docker images.
-pr-branch string
(default: $USER/BRANCH-vulnfix) Upstream branch to push to (user-confirmed first).
-sync-dockerfiles-from
diff --git a/ops/gmpctl/cmd_release.go b/ops/gmpctl/cmd_release.go
index 2ce2a9f85d..0a0e6942d6 100644
--- a/ops/gmpctl/cmd_release.go
+++ b/ops/gmpctl/cmd_release.go
@@ -83,22 +83,22 @@ func release() error {
return err
}
+ mustCreateOrRecreateTag(dir, tag)
if !mustIsRemoteUpToDate(dir, branch) {
- if confirmf("About to git push state from %q to \"origin/%v\" for %q tag; are you sure?", dir, branch, tag) {
- // We are in detached state, so use the HEAD reference.
- mustPush(dir, fmt.Sprintf("HEAD:%v", branch))
+ if confirmf("About to create a signed tag %q and git push state (HEAD:%v) and tag %q from %q to \"origin\"; are you sure?", tag, branch, tag, dir) {
+ mustPush(dir, fmt.Sprintf("HEAD:%v", branch), tag)
} else {
return errors.New("aborting")
}
- }
-
- // TODO(bwplotka): Check if tag exists.
- mustCreateSignedTag(dir, tag)
- if confirmf("About to git push %q tag from %q to \"origin/%v\"; are you sure?", tag, dir, branch) {
- mustPush(dir, tag)
} else {
- return errors.New("aborting")
+ // Retagging only. This can happen if someone wants to continue the script.
+ if confirmf("About to create a signed tag %q and push it from %q to \"origin\"; are you sure?", tag, dir) {
+ mustPush(dir, tag)
+ } else {
+ return errors.New("aborting")
+ }
}
+
if confirmf("Do you want to remove the %v worktree (recommended)?", dir) {
proj.RemoveWorkDir(cfg.Directory, dir)
}
diff --git a/ops/gmpctl/cmd_vulnfix.go b/ops/gmpctl/cmd_vulnfix.go
index ef94a61744..6fd758d146 100644
--- a/ops/gmpctl/cmd_vulnfix.go
+++ b/ops/gmpctl/cmd_vulnfix.go
@@ -19,6 +19,10 @@ import (
"flag"
"fmt"
"os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
)
var (
@@ -26,6 +30,7 @@ var (
vulnfixBranch = vulnfixFlags.String("b", "", "Release branch to work on; Project is auto-detected from this")
vulnfixPRBranch = vulnfixFlags.String("pr-branch", "", "(default: $USER/BRANCH-vulnfix) Upstream branch to push to (user-confirmed first).")
vulnfixSyncDockerfilesFrom = vulnfixFlags.Bool("sync-dockerfiles-from", false, "Optional branch name to sync Dockerfiles from. Useful when things changed.")
+ vulnfixGoVersion = vulnfixFlags.String("go-version", "", "Go minor version to use for docker images.")
)
// Attempt a minimal dependency upgrade to solve fixable vulnerabilities.
@@ -74,10 +79,22 @@ func vulnfix() error {
// Refresh.
mustFetchAll(dir)
+ goVersion := *vulnfixGoVersion
+ if goVersion == "" {
+ goVersion, err = detectGoMinorVersion(dir)
+ if err != nil {
+ return fmt.Errorf("could not detect Go version from Dockerfile: %v", err)
+ }
+ }
+ logf("Using Go version: %s", goVersion)
+
opts := []string{
fmt.Sprintf("DIR=%v", dir),
fmt.Sprintf("BRANCH=%v", branch),
fmt.Sprintf("PROJECT=%v", proj.Name),
+ fmt.Sprintf("GO_VERSION=%v", goVersion),
+ // We use the local toolchain to avoid downloading remote toolchains during vulnfix.
+ fmt.Sprintf("GOTOOLCHAIN=local"),
}
if *vulnfixSyncDockerfilesFrom {
opts = append(opts, "SYNC_DOCKERFILES_FROM=true")
@@ -88,7 +105,13 @@ func vulnfix() error {
return err
}
- // TODO: Warn of unstaged files at this point.
+ if proj.Name != "prometheus-engine" {
+ if err := fixOtelSchemaConflict(dir); err != nil {
+ return err
+ }
+ }
+
+ // TODO: Warn of any unstaged files at this point.
// Commit if anything is staged.
msg := fmt.Sprintf("google patch[deps]: fix %v vulnerabilities", branch)
@@ -110,6 +133,7 @@ func vulnfix() error {
// We are in detached state, so be explicit what to push and from where, by recreating the local prBranch.
mustRecreateBranch(dir, prBranch)
mustForcePush(dir, prBranch)
+ mustEnsurePullRequest(dir, branch, prBranch, msg, "Updating Go and image vulnerabilities using"+wrapCode("./gmpctl.sh vulnfix"))
} else {
return errors.New("aborting")
}
@@ -119,3 +143,153 @@ func vulnfix() error {
}
return nil
}
+
+func detectGoMinorVersion(dir string) (string, error) {
+ var dockerfiles []string
+ err := filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ name := info.Name()
+ if name == "third_party" || name == "ui" || name == "vendor" || name == "node_modules" || name == ".git" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if strings.HasPrefix(info.Name(), "Dockerfile") {
+ dockerfiles = append(dockerfiles, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return "", err
+ }
+ if len(dockerfiles) == 0 {
+ return "", fmt.Errorf("no Dockerfile found in %s", dir)
+ }
+
+ re := regexp.MustCompile(`(?:google-go\.pkg\.dev/golang|golang):([0-9]+\.[0-9]+)`)
+
+ for _, df := range dockerfiles {
+ content, err := os.ReadFile(df)
+ if err != nil {
+ continue
+ }
+ matches := re.FindSubmatch(content)
+ if len(matches) > 1 {
+ return string(matches[1]), nil
+ }
+ }
+ return "", fmt.Errorf("could not find golang image in any Dockerfile under %s", dir)
+}
+
+func wrapCode(s string) string {
+ return "\n```\n" + s + "\n```\n"
+}
+
+// It's a common occurrence that schema import goes off-sync with the go module, fix it.
+func fixOtelSchemaConflict(dir string) error {
+ targetVersion, err := detectSchemaVersion(dir)
+ if err != nil {
+ return err
+ }
+ if targetVersion == "" {
+ return nil
+ }
+ return replaceOtelImports(dir, targetVersion)
+}
+
+// TODO(bwplotka): AI figured some way, but there's likely a better way to tell?
+func detectSchemaVersion(dir string) (string, error) {
+ tmpFile := filepath.Join(dir, "gmpctl_tmp_schema.go")
+ tmpCode := `package main
+
+import (
+ "fmt"
+ "go.opentelemetry.io/otel/sdk/resource"
+)
+
+func main() {
+ r := resource.Default()
+ fmt.Print(r.SchemaURL())
+}
+`
+ if err := os.WriteFile(tmpFile, []byte(tmpCode), 0o644); err != nil {
+ return "", fmt.Errorf("failed to write temp file: %w", err)
+ }
+ defer os.Remove(tmpFile)
+
+ cmd := exec.Command("go", "run", "gmpctl_tmp_schema.go")
+ cmd.Dir = dir
+ cmd.Stderr = os.Stderr
+ out, err := cmd.Output()
+ if err != nil {
+ // If it fails to run, it might be because otel/sdk is not in dependencies,
+ // or some other issue. We log and ignore to not block the whole pipeline if it's not relevant.
+ logf("Warning: failed to run temp schema detector: %v", err)
+ return "", nil
+ }
+
+ schemaURL := string(out)
+ if schemaURL == "" {
+ logf("No schema URL detected from SDK resource")
+ return "", nil
+ }
+
+ reVersion := regexp.MustCompile(`([0-9]+\.[0-9]+\.[0-9]+)$`)
+ matches := reVersion.FindStringSubmatch(schemaURL)
+ if len(matches) < 2 {
+ logf("Could not parse version from schema URL: %s", schemaURL)
+ return "", nil
+ }
+ return "v" + matches[1], nil
+}
+
+func replaceOtelImports(dir string, targetVersion string) error {
+ logf("Detected target OpenTelemetry schema version: %s", targetVersion)
+
+ reImport := regexp.MustCompile(`"go\.opentelemetry\.io/otel/semconv/(v1\.[0-9]+\.[0-9]+)"`)
+ reSchemaURLUse := regexp.MustCompile(`\.SchemaURL\b`)
+
+ if err := filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ name := info.Name()
+ if name == "vendor" || name == "third_party" || name == ".git" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(info.Name(), ".go") {
+ return nil
+ }
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ if !reImport.Match(content) || !reSchemaURLUse.Match(content) {
+ return nil
+ }
+
+ newContent := reImport.ReplaceAllFunc(content, func(match []byte) []byte {
+ return []byte(fmt.Sprintf(`"go.opentelemetry.io/otel/semconv/%s"`, targetVersion))
+ })
+
+ if string(newContent) != string(content) {
+ logf("Updating OTEL semconv imports to %s in %s", targetVersion, path)
+ if err := os.WriteFile(path, newContent, 0o644); err != nil {
+ return fmt.Errorf("failed to write file %s: %w", path, err)
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+ mustAddAll(dir)
+ return nil
+}
diff --git a/ops/gmpctl/git.go b/ops/gmpctl/git.go
index 4de71c9dbe..4ca7ef9dec 100644
--- a/ops/gmpctl/git.go
+++ b/ops/gmpctl/git.go
@@ -49,21 +49,68 @@ func mustFetchAll(dir string) {
}
}
+func getTTY(dir string) string {
+ out, err := runCommand(&cmdOpts{Dir: dir, HideOutputs: true}, "tty")
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(out)
+}
+
func mustCreateSignedTag(dir, tag string) {
logf("Creating a signed tag %v...", tag)
- // explicit TTY is often needed on Macs.
+ // Ensure TTY is set for GPG signing.
+ var envs []string
+ if tty := getTTY(dir); tty != "" {
+ envs = append(envs, "GPG_TTY="+tty)
+ }
+
// TODO(bwplotka): Consider adding v0.x second tag for Prometheus fork (similar to how v0.300 Prometheus releases are structured).
// This is to have a little bit cleaner prometheus-engine go.mod version against the fork.
if _, err := runCommand(
- &cmdOpts{Dir: dir},
- "bash", "-c",
- fmt.Sprintf("GPG_TTY=$(tty) git tag -s %v -m %v", tag, tag),
+ &cmdOpts{Dir: dir, Envs: envs},
+ "git", "tag", "-s", tag, "-m", tag,
); err != nil {
panicf(err.Error())
}
}
+func mustCreateOrRecreateTag(dir, tag string) {
+ // Check if the tag exists on origin, and if so, finish
+ _, errLs := runCommand(&cmdOpts{Dir: dir, HideOutputs: true}, "git", "ls-remote", "--exit-code", "--tags", "origin", "refs/tags/"+tag)
+ if errLs == nil {
+ panicf("This tag %v was already pushed, chose a different one!", tag)
+ }
+
+ // Check if the tag already exists locally.
+ tagCommit, err := runCommand(&cmdOpts{Dir: dir, HideOutputs: true}, "git", "rev-parse", "--verify", "-q", "refs/tags/"+tag+"^{commit}")
+ if err == nil {
+ // Tag exists locally!
+ headCommit, err := runCommand(&cmdOpts{Dir: dir, HideOutputs: true}, "git", "rev-parse", "HEAD")
+ if err != nil {
+ panicf("failed to get HEAD commit: %v", err)
+ }
+
+ tagCommit = strings.TrimSpace(tagCommit)
+ headCommit = strings.TrimSpace(headCommit)
+
+ if tagCommit == headCommit {
+ logf("Tag %q already exists locally and points to HEAD (%s). Skipping recreation.", tag, headCommit)
+ return
+ }
+ logf("Tag %q exists locally but points to commit %s, while HEAD is at %s. Re-tagging...", tag, tagCommit, headCommit)
+
+ // Delete the local tag.
+ if _, err := runCommand(&cmdOpts{Dir: dir}, "git", "tag", "-d", tag); err != nil {
+ panicf("failed to delete local tag %s: %v", tag, err)
+ }
+ }
+
+ // Create the signed tag.
+ mustCreateSignedTag(dir, tag)
+}
+
// mustIsRemoteUpToDate returns true if HEAD points to the same commit as
// the origin branch
func mustIsRemoteUpToDate(dir, branch string) bool {
@@ -84,9 +131,10 @@ func mustIsRemoteUpToDate(dir, branch string) bool {
return strings.TrimSpace(localHead) == strings.TrimSpace(remoteHead)
}
-func mustPush(dir, what string) {
- logf("Pushing %v...", what)
- if _, err := runCommand(&cmdOpts{Dir: dir}, "git", "push", "origin", what); err != nil {
+func mustPush(dir string, what ...string) {
+ logf("Pushing %v...", strings.Join(what, " "))
+ args := append([]string{"git", "push", "origin"}, what...)
+ if _, err := runCommand(&cmdOpts{Dir: dir}, args...); err != nil {
panicf("failed to push: %v", err)
}
}
@@ -106,6 +154,12 @@ func mustRecreateBranch(dir, branch string) {
}
}
+func mustAddAll(dir string) {
+ if _, err := runCommand(&cmdOpts{Dir: dir}, "git", "add", "--all"); err != nil {
+ panicf("failed to git add all: %v", err)
+ }
+}
+
func checkoutBranch(dir, branchName string) {
if _, err := runCommand(&cmdOpts{Dir: dir}, "git", "checkout", branchName); err != nil {
panicf(err.Error())
diff --git a/ops/gmpctl/github.go b/ops/gmpctl/github.go
new file mode 100644
index 0000000000..d22056d4f7
--- /dev/null
+++ b/ops/gmpctl/github.go
@@ -0,0 +1,67 @@
+// Copyright 2026 Google LLC
+//
+// 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
+//
+// https://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.
+
+package main
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+type prInfo struct {
+ URL string `json:"url"`
+}
+
+func mustEnsurePullRequest(dir, baseBranch, headBranch, title, body string) {
+ logf("Checking for existing pull request for %v...", headBranch)
+
+ // gh pr list --head
--base --state open --json url
+ out, err := runCommand(
+ &cmdOpts{Dir: dir, HideOutputs: true},
+ "gh", "pr", "list",
+ "--head", headBranch,
+ "--base", baseBranch,
+ "--state", "open",
+ "--json", "url",
+ )
+ if err != nil {
+ panicf("failed to check existing PR: %v", err)
+ }
+
+ var prs []prInfo
+ if err := json.Unmarshal([]byte(out), &prs); err != nil {
+ panicf("failed to parse gh output: %v, output: %q", err, out)
+ }
+
+ if len(prs) > 0 {
+ logf("Pull request already exists: %v", prs[0].URL)
+ return
+ }
+
+ logf("Creating pull request from %v to %v...", headBranch, baseBranch)
+
+ // gh pr create --title --body --base --head
+ prURL, err := runCommand(
+ &cmdOpts{Dir: dir},
+ "gh", "pr", "create",
+ "--title", title,
+ "--body", body,
+ "--base", baseBranch,
+ "--head", headBranch,
+ )
+ if err != nil {
+ panicf("failed to create pull request: %v", err)
+ }
+ logf("Pull request created: %v", strings.TrimSpace(prURL))
+}
diff --git a/ops/gmpctl/gmp.go b/ops/gmpctl/gmp.go
index 937ae8fc59..4d27bb3428 100644
--- a/ops/gmpctl/gmp.go
+++ b/ops/gmpctl/gmp.go
@@ -62,6 +62,8 @@ func projectFromBranch(branch string) (Project, bool) {
return Alertmanager, true
case PrometheusEngine.BranchRE.MatchString(branch):
return PrometheusEngine, true
+ case branch == "main":
+ return PrometheusEngine, true
}
return Project{}, false
}
diff --git a/ops/gmpctl/lib.sh b/ops/gmpctl/lib.sh
index 3e0d4bdaeb..7c65c44f51 100755
--- a/ops/gmpctl/lib.sh
+++ b/ops/gmpctl/lib.sh
@@ -167,7 +167,7 @@ release-lib::gomod_vulnfix() {
fi
echo "🔄 Updating module '${mod_path}' to version '${desired_version}'..."
- ${SED} -i "s|\( ${mod_path} \).*|\1${desired_version}|" "${dir}/go.mod"
+ ${SED} -i "s|\(${mod_path}[ ][ ]*\)v[^ ]*|\1${desired_version}|g" "${dir}/go.mod"
done <"${vuln_file}"
echo "🔄 Resolving ${dir}/go.mod..."
pushd "${dir}"
@@ -244,7 +244,7 @@ release-lib::dockerfiles() {
log_err "dir arg is required."
return 1
fi
- find "${dir}" -name "Dockerfile*" | grep -v "${dir}/third_party/" | grep -v "${dir}/hack/" | grep -v "${dir}/ui/" | grep -v "vendor/" | grep -v "node_modules/"
+ find "${dir}" \( -name "third_party" -o -name "ui" -o -name "vendor" -o -name "node_modules" -o -name ".git" \) -prune -o -name "Dockerfile*" -print
}
# Return all images used in a Dockerfile, delimited by new-line.
@@ -378,6 +378,8 @@ release-lib::dockerfile_update_image() {
local all_tags=$(gcrane ls "${image}" --json | jq --raw-output '.tags[]' | sort -V)
# Exclude RC images.
all_tags=$(echo "${all_tags}" | grep -v "rc.*")
+ # Exclude arch-specific tags.
+ all_tags=$(echo "${all_tags}" | grep -E -v "(-linux-|-arm64|-amd64)")
# Prefix allows sticking to e.g. latest minor.
all_tags=$(echo "${all_tags}" | grep "${tag_prefix}")
local latest_tag=$(echo "${all_tags}" | tail -n1)
@@ -407,8 +409,6 @@ release-lib::idemp::manifests_bash_image_bump() {
return 1
fi
- go install github.com/mikefarah/yq/v4@latest
-
local values_file="${dir}/charts/values.global.yaml"
# TODO: Not enough, this has to check actual manifests.
local bash_tag=$(yq '.images.bash.tag' "${values_file}")
@@ -442,17 +442,22 @@ release-lib::manifests_regen() {
return 1
fi
- # TODO(bwplotka): Manage deps better. It's getting confusing what bins we should use (worktree bingo? script bingo?).
- go install helm.sh/helm/v3/cmd/helm@latest
- go install github.com/google/addlicense@latest
- go install github.com/mikefarah/yq/v4@latest
-
# Hack: Do the bingo variable swap. This allows injecting our own.
# This is faster than running requiring bingo and running bingo get.
- cp "${dir}/.bingo/variables.env" "${dir}/.bingo/variables.env.bak"
- echo "#!/bin/bash" >"${dir}/.bingo/variables.env" # Clean the file.
+ # NOTE: Only needed before 0.19.
+ if [[ -f "${dir}/.bingo/variables.env" ]]; then
+ cp "${dir}/.bingo/variables.env" "${dir}/.bingo/variables.env.bak"
+ echo "#!/bin/bash" >"${dir}/.bingo/variables.env" # Clean the file.
+ fi
+
+ echo "🔄 Regenerating manifests..."
+ # Regenerate.
YQ="$(which yq)" HELM="$(which helm)" ADDLICENSE="$(which addlicense)" bash "${dir}/hack/presubmit.sh" manifests
- cp "${dir}/.bingo/variables.env.bak" "${dir}/.bingo/variables.env"
+
+ # NOTE: Only needed before 0.19.
+ if [[ -f "${dir}/.bingo/variables.env.bak" ]]; then
+ mv "${dir}/.bingo/variables.env.bak" "${dir}/.bingo/variables.env"
+ fi
echo "✅ Manifests regenerated"
return 0
diff --git a/ops/gmpctl/main_test.go b/ops/gmpctl/main_test.go
new file mode 100644
index 0000000000..f83eda1bb2
--- /dev/null
+++ b/ops/gmpctl/main_test.go
@@ -0,0 +1,203 @@
+// Copyright 2026 Google LLC
+//
+// 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
+//
+// https://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.
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDetectGoMinorVersion(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ files map[string]string
+ expected string
+ }{
+ {
+ name: "google-go.pkg.dev image",
+ files: map[string]string{
+ "Dockerfile": `
+FROM --platform=$BUILDPLATFORM google-go.pkg.dev/golang:1.26.2@sha256:1bee769a7a50eea7730ac31f75182ae2614f50a70902407312db390a7c7cb2ff AS buildbase
+ARG TARGETOS
+`,
+ },
+ expected: "1.26",
+ },
+ {
+ name: "standard golang image",
+ files: map[string]string{
+ "Dockerfile": `
+FROM golang:1.23.5 AS build
+`,
+ },
+ expected: "1.23",
+ },
+ {
+ name: "skip directories",
+ files: map[string]string{
+ "third_party/Dockerfile": "FROM golang:1.20.0",
+ "hack/Dockerfile": "FROM golang:1.20.0",
+ "ui/Dockerfile": "FROM golang:1.20.0",
+ "vendor/Dockerfile": "FROM golang:1.20.0",
+ "node_modules/Dockerfile": "FROM golang:1.20.0",
+ "Dockerfile": "FROM golang:1.24.1",
+ },
+ expected: "1.24",
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "gmpctl-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
+ for path, content := range tt.files {
+ fullPath := filepath.Join(tempDir, path)
+ if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ version, err := detectGoMinorVersion(tempDir)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if version != tt.expected {
+ t.Errorf("expected version %s, got %s", tt.expected, version)
+ }
+ })
+ }
+}
+
+func TestReplaceOtelImports(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ files map[string]string
+ targetVersion string
+ expected map[string]string
+ }{
+ {
+ name: "replace import when SchemaURL is used",
+ files: map[string]string{
+ "tracing.go": `package tracing
+import (
+ semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
+)
+func init() {
+ _ = semconv.SchemaURL
+}
+`,
+ },
+ targetVersion: "v1.40.0",
+ expected: map[string]string{
+ "tracing.go": `package tracing
+import (
+ semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
+)
+func init() {
+ _ = semconv.SchemaURL
+}
+`,
+ },
+ },
+ {
+ name: "do not replace import when SchemaURL is NOT used",
+ files: map[string]string{
+ "queue.go": `package queue
+import (
+ semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
+)
+func init() {
+ _ = semconv.HTTPResendCount
+}
+`,
+ },
+ targetVersion: "v1.40.0",
+ expected: map[string]string{
+ "queue.go": `package queue
+import (
+ semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
+)
+func init() {
+ _ = semconv.HTTPResendCount
+}
+`,
+ },
+ },
+ {
+ name: "replace with alias",
+ files: map[string]string{
+ "tracing.go": `package tracing
+import (
+ conventions "go.opentelemetry.io/otel/semconv/v1.39.0"
+)
+func init() {
+ _ = conventions.SchemaURL
+}
+`,
+ },
+ targetVersion: "v1.40.0",
+ expected: map[string]string{
+ "tracing.go": `package tracing
+import (
+ conventions "go.opentelemetry.io/otel/semconv/v1.40.0"
+)
+func init() {
+ _ = conventions.SchemaURL
+}
+`,
+ },
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "gmpctl-otel-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
+ for path, content := range tt.files {
+ fullPath := filepath.Join(tempDir, path)
+ if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := runCommand(&cmdOpts{Dir: tempDir, HideOutputs: true}, "git", "init", "-b", "main"); err != nil {
+ t.Fatal(err)
+ }
+
+ err = replaceOtelImports(tempDir, tt.targetVersion)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ for path, expectedContent := range tt.expected {
+ fullPath := filepath.Join(tempDir, path)
+ content, err := os.ReadFile(fullPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(content) != expectedContent {
+ t.Errorf("file %s: expected content:\n%s\ngot:\n%s", path, expectedContent, string(content))
+ }
+ }
+ })
+ }
+}
diff --git a/ops/gmpctl/vulnfix.sh b/ops/gmpctl/vulnfix.sh
index b1c5fbbc29..14f9565fc9 100644
--- a/ops/gmpctl/vulnfix.sh
+++ b/ops/gmpctl/vulnfix.sh
@@ -33,9 +33,6 @@ fi
source "${SCRIPT_DIR}/lib.sh"
-# TODO: Find better way. Go tool grane is tricky as we run in different directory.
-go install github.com/google/go-containerregistry/cmd/gcrane@latest
-
# Also accepts SYNC_DOCKERFILES_FROM.
if [[ -z "${DIR}" ]]; then
@@ -53,6 +50,11 @@ if [[ -z "${PROJECT}" ]]; then
exit 1
fi
+if [[ -z "${GO_VERSION}" ]]; then
+ log_err "GO_VERSION envvar is required."
+ exit 1
+fi
+
echo "${DIR}"
echo "${SCRIPT_DIR}"
@@ -71,18 +73,12 @@ fi
# Docker images bumps.
-# Get first dockerfile Go version. We will use this version to find minor version to stick to.
-go_version=$(release-lib::dockerfile_go_version "${DOCKERFILES[0]}")
-if [[ -z "${go_version}" ]]; then
- echo "❌ can't find any golang image in ${DOCKERFILES[0]}"
- exit 1
-fi
-
# TODO: git add charts & vendor for old projects?
# Update our images.
for dockerfile in "${DOCKERFILES[@]}"; do
- release-lib::dockerfile_update_image "${dockerfile}" "google-go.pkg.dev/golang" $(echo "${go_version}" | cut -d '.' -f 1-2)
+ # TOOD(bwplotka): Bump gcr.io/distroless/static-debian12:nonroot images as well https://github.com/GoogleCloudPlatform/prometheus-engine/pull/1933
+ release-lib::dockerfile_update_image "${dockerfile}" "google-go.pkg.dev/golang" $(echo "${GO_VERSION}" | cut -d '.' -f 1-2)
release-lib::dockerfile_update_image "${dockerfile}" "gke.gcr.io/gke-distroless/libc" "gke_distroless_"
pushd "${DIR}"
git add "${dockerfile}"
@@ -116,7 +112,6 @@ if [[ "no vulnerabilities" != $(cat "${vuln_file}") ]]; then
fi
# Check if that helped.
- echo "⚠️ This will fail on older branches with vendoring; in this case, simply go to ${DIR}, run 'go mod vendor' and rerun."
release-lib::vulnlist "${DIR}" "${vuln_file}"
if [[ "no vulnerabilities" != $(cat "${vuln_file}") ]]; then
echo "❌ After go mod update some vulnerabilities are still found; go to ${DIR} and resolve it manually (select not reusing the ./vulnlist.txt file) and rerun."
diff --git a/ops/gmpctl/vulnupdatelist/main.go b/ops/gmpctl/vulnupdatelist/main.go
index 3a2e09fb7f..08b7c70e89 100644
--- a/ops/gmpctl/vulnupdatelist/main.go
+++ b/ops/gmpctl/vulnupdatelist/main.go
@@ -43,8 +43,9 @@ import (
var (
goVersion = flag.String("go-version", "", "Go version to test vulnerabilities in (stdlib). Otherwise the `go env GOVERSION` is used")
dir = flag.String("dir", ".", "Where to run the script from")
- nvdAPIKey = flag.String("nvd-api-key", "", "API Key for avoiding rate-limiting on severity checks; see https://nvd.nist.gov/developers/request-an-api-key")
- onlyFixed = flag.Bool("only-fixed", false, "Don't print vulnerable modules without fixed version; note: fixed version often means sometimes that a new major version contains a fix.")
+ nvdAPIKey = flag.String("nvd-api-key", "", "API Key for avoiding rate-limiting on severity checks; see https://nvd.nist.gov/developers/request-an-api-key")
+ onlyFixed = flag.Bool("only-fixed", false, "Don't print vulnerable modules without fixed version; note: fixed version often means sometimes that a new major version contains a fix.")
+ ignoreCVEs = flag.String("ignore-cves", "", "Comma-separated list of CVE/GO IDs to ignore")
)
// UpdateList presents the minimum version to upgrade to solve all CVEs with
diff --git a/ops/gmpctl/vulnupdatelist/nvdapi.go b/ops/gmpctl/vulnupdatelist/nvdapi.go
index 3a59085f7c..f914697441 100644
--- a/ops/gmpctl/vulnupdatelist/nvdapi.go
+++ b/ops/gmpctl/vulnupdatelist/nvdapi.go
@@ -21,9 +21,15 @@ import (
"log/slog"
"net/http"
"strings"
+ "sync"
"time"
)
+var (
+ severityCache = make(map[string]string)
+ cacheMu sync.RWMutex
+)
+
// NVDResponse is the top-level object for the NVD CVE API.
type NVDResponse struct {
Vulnerabilities []struct {
@@ -35,6 +41,11 @@ type NVDResponse struct {
BaseSeverity string `json:"baseSeverity"`
} `json:"cvssData"`
} `json:"cvssMetricV31"`
+ CVSSMetricV30 []struct {
+ CVSSData struct {
+ BaseSeverity string `json:"baseSeverity"`
+ } `json:"cvssData"`
+ } `json:"cvssMetricV30"`
} `json:"metrics"`
} `json:"cve"`
} `json:"vulnerabilities"`
@@ -42,6 +53,13 @@ type NVDResponse struct {
// getCVSSSeverity fetches vulnerability details from the NVD API and returns the CVSS V3 severity.
func getCVSSSeverity(apiKey, cveID string) (string, error) {
+ cacheMu.RLock()
+ if sev, ok := severityCache[cveID]; ok {
+ cacheMu.RUnlock()
+ return sev, nil
+ }
+ cacheMu.RUnlock()
+
// https://nvd.nist.gov/developers/vulnerabilities
apiURL := fmt.Sprintf("https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=%s", cveID)
@@ -75,11 +93,23 @@ func getCVSSSeverity(apiKey, cveID string) (string, error) {
if len(nvdResponse.Vulnerabilities) > 0 {
metrics := nvdResponse.Vulnerabilities[0].CVE.Metrics
+ var sev string
if len(metrics.CVSSMetricV31) > 0 {
- return metrics.CVSSMetricV31[0].CVSSData.BaseSeverity, nil
+ sev = metrics.CVSSMetricV31[0].CVSSData.BaseSeverity
+ } else if len(metrics.CVSSMetricV30) > 0 {
+ sev = metrics.CVSSMetricV30[0].CVSSData.BaseSeverity
+ }
+ if sev != "" {
+ cacheMu.Lock()
+ severityCache[cveID] = sev
+ cacheMu.Unlock()
+ return sev, nil
}
}
+ cacheMu.Lock()
+ severityCache[cveID] = "UNKNOWN"
+ cacheMu.Unlock()
return "UNKNOWN", nil
}
diff --git a/ops/gmpctl/vulnupdatelist/vuln.go b/ops/gmpctl/vulnupdatelist/vuln.go
index 35548da9da..9e72f4d4bd 100644
--- a/ops/gmpctl/vulnupdatelist/vuln.go
+++ b/ops/gmpctl/vulnupdatelist/vuln.go
@@ -16,6 +16,7 @@ package main
import (
"encoding/json"
+ "fmt"
"io"
"log/slog"
"maps"
@@ -80,6 +81,35 @@ type FindingTrace struct {
Version string `json:"version"`
}
+var ignoredCVEs = map[string]string{
+ // TODO(bwplotka): We are not affected by CVE-2026-44903, CVE-2026-42151, CVE-2026-42154, etc. in prometheus/prometheus. They are going to be fixed with the unfork.
+ "CVE-2026-44903": "Not affected; going to be fixed with the unfork",
+ "CVE-2026-42151": "Not affected; going to be fixed with the unfork",
+ "CVE-2026-42154": "Not affected; going to be fixed with the unfork",
+}
+
+func isIgnoredCVE(osvID string, aliases []string, cveID string, module string) (string, bool) {
+ if module == "github.com/prometheus/prometheus" {
+ return "Not affected; going to be fixed with the unfork", true
+ }
+ for _, id := range append([]string{osvID, cveID}, aliases...) {
+ if id == "" {
+ continue
+ }
+ if reason, ok := ignoredCVEs[id]; ok {
+ return fmt.Sprintf("%s (%s)", id, reason), true
+ }
+ if *ignoreCVEs != "" {
+ for _, userIgnored := range strings.Split(*ignoreCVEs, ",") {
+ if strings.TrimSpace(userIgnored) == id {
+ return fmt.Sprintf("%s (ignored via command line)", id), true
+ }
+ }
+ }
+ }
+ return "", false
+}
+
// compileUpdateList decodes the JSON stream from govulncheck and extracts
// a list of modules that need to be updated to a fixed version.
func compileUpdateList(jsonData io.Reader, onlyFixed bool) ([]UpdateList, error) {
@@ -110,9 +140,11 @@ func compileUpdateList(jsonData io.Reader, onlyFixed bool) ([]UpdateList, error)
osv := osvs[v.Finding.OSVID]
cve := CVE{}
allCVEs := v.Finding.OSVID
+ var aliases []string
if osv != nil {
cve = getCVEDetails(*nvdAPIKey, *osv)
allCVEs = osv.CVEs()
+ aliases = osv.Aliases
} else {
slog.Error("Malformed govulncheck input; a finding without a OSV entry; assuming unkown severity.", "finding.osv", v.Finding.OSVID)
cve.ID = v.Finding.OSVID // Fallback to GO ID
@@ -123,6 +155,11 @@ func compileUpdateList(jsonData io.Reader, onlyFixed bool) ([]UpdateList, error)
continue
}
+ if ignoreReason, ignored := isIgnoredCVE(v.Finding.OSVID, aliases, cve.ID, v.Finding.Trace[0].Module); ignored {
+ slog.Info("Ignoring vulnerability finding", "mod", v.Finding.Trace[0].Module, "reason", ignoreReason)
+ continue
+ }
+
module := v.Finding.Trace[0].Module
var fixVersion *semver.Version
diff --git a/tools/go.mod b/tools/go.mod
index 8db9a71d86..eff29a18d4 100644
--- a/tools/go.mod
+++ b/tools/go.mod
@@ -99,7 +99,7 @@ require (
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
- github.com/containerd/containerd v1.7.33 // indirect
+ github.com/containerd/containerd v1.7.32 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
diff --git a/tools/go.sum b/tools/go.sum
index 45d94c3907..f6aac52339 100644
--- a/tools/go.sum
+++ b/tools/go.sum
@@ -347,8 +347,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
-github.com/containerd/containerd v1.7.33 h1:iAkYGC/ifR/V+0eR4iXWHNGYUF0DF2PmGV5iz4Irj5M=
-github.com/containerd/containerd v1.7.33/go.mod h1:gSbSCVjPCdkfJCjyrzz7aRC+xFlqVbatNpfHfVCYGUM=
+github.com/containerd/containerd v1.7.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qHBs+sbY8=
+github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=