diff --git a/ops/gmpctl.sh b/ops/gmpctl.sh index 794a7a0828..9a45cb7120 100755 --- a/ops/gmpctl.sh +++ b/ops/gmpctl.sh @@ -26,6 +26,12 @@ 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 + # NOTE gmpctl expects the gmpctl directory to be present on execution for # local bash scripts and configuration. # 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..e2f7759a50 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 toolchain and docker images.") ) // Attempt a minimal dependency upgrade to solve fixable vulnerabilities. @@ -74,21 +79,45 @@ 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 hardcode toolchain everywhere for now, until we have deps that require higher version. + // This makes it simpler to maintain dependencies across old versions, forks and tools (e.g. code gen). + fmt.Sprintf("GOTOOLCHAIN=go1.25.0"), } if *vulnfixSyncDockerfilesFrom { opts = append(opts, "SYNC_DOCKERFILES_FROM=true") } + // Update go version in go.mod to what toolchain is set to if it was updated by accident + // otherwise it won't work with our toolchain. + if _, err := runCommand(&cmdOpts{Dir: dir, Envs: opts}, "go", "mod", "edit", "-go=1.25.0"); err != nil { + return fmt.Errorf("failed to update go version in go.mod: %v", err) + } // TODO(bwplotka): Add NPM vulnfix. if err := runLocalBash(dir, opts, "vulnfix.sh"); err != nil { 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 +139,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 +149,152 @@ func vulnfix() error { } return nil } + +func detectGoMinorVersion(dir string) (string, error) { + var dockerfiles []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, 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 + 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.Walk(dir, func(path string, info os.FileInfo, 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..8a53d4fcc0 100644 --- a/ops/gmpctl/git.go +++ b/ops/gmpctl/git.go @@ -49,21 +49,65 @@ 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. + envs := []string{"GPG_TTY=" + getTTY(dir)} + // 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 +128,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 +151,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 <body> --base <base> --head <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 4b3c76a385..ae0f566e1b 100644 --- a/ops/gmpctl/gmp.go +++ b/ops/gmpctl/gmp.go @@ -61,6 +61,8 @@ func projectFromBranch(branch string) (Project, bool) { return Alertmanager, true case PrometheusEngine.BranchRE.MatchString(branch): return PrometheusEngine, true + case strings.Compare(branch, "main") == 0: + return PrometheusEngine, true } return Project{}, false } diff --git a/ops/gmpctl/lib.sh b/ops/gmpctl/lib.sh index 3e0d4bdaeb..c45eb81ecf 100755 --- a/ops/gmpctl/lib.sh +++ b/ops/gmpctl/lib.sh @@ -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 "Dockerfile*" | grep -v "${dir}/third_party/" | grep -v "${dir}/ui/" | grep -v "vendor/" | grep -v "node_modules/" } # Return all images used in a Dockerfile, delimited by new-line. @@ -407,8 +407,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 +440,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..3c9454505a --- /dev/null +++ b/ops/gmpctl/main_test.go @@ -0,0 +1,187 @@ +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) + } + } + + 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/nvdapi.go b/ops/gmpctl/vulnupdatelist/nvdapi.go index 3a59085f7c..4b1da64e6a 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 { @@ -42,6 +48,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) @@ -76,10 +89,17 @@ func getCVSSSeverity(apiKey, cveID string) (string, error) { if len(nvdResponse.Vulnerabilities) > 0 { metrics := nvdResponse.Vulnerabilities[0].CVE.Metrics if len(metrics.CVSSMetricV31) > 0 { - return metrics.CVSSMetricV31[0].CVSSData.BaseSeverity, nil + sev := metrics.CVSSMetricV31[0].CVSSData.BaseSeverity + cacheMu.Lock() + severityCache[cveID] = sev + cacheMu.Unlock() + return sev, nil } } + cacheMu.Lock() + severityCache[cveID] = "UNKNOWN" + cacheMu.Unlock() return "UNKNOWN", nil }