Skip to content

PO to GMP Migration Tool: Orchestrator & CLI Boilerplate#1961

Draft
karthunni wants to merge 2 commits into
mainfrom
karthunni/podmonitor-migration
Draft

PO to GMP Migration Tool: Orchestrator & CLI Boilerplate#1961
karthunni wants to merge 2 commits into
mainfrom
karthunni/podmonitor-migration

Conversation

@karthunni

Copy link
Copy Markdown
Collaborator

This PR implements the migration pipeline and CLI carriage boilerplate for the gmp-migrate tool.

  • CLI Carriage: Command-line entrypoint supporting files, directories, and stdin/stdout streams.
  • Parsed-Resource Cache: Stores parsed namespaced resources for cross-reference during migrations.
  • Migration Pipeline: Orchestrator handling YAML parsing and converter execution..
  • Decoupled Logging: Structured logging (LogMessage, MigrationReport) routed to Stderr to keep Stdout clean for Unix piping.
  • Unit Tests: Validates cache isolation, converter registration, and logging.

@karthunni karthunni self-assigned this Jun 17, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new command-line tool gmp-migrate and a supporting migrate package to facilitate the migration of Prometheus Operator configurations to Google Managed Prometheus (GMP). The core logic parses Kubernetes manifests, caches them for cross-resource resolution, and runs registered converters to output translated manifests. The review feedback focuses on improving the tool's robustness and testability. Key recommendations include: ensuring deterministic resource conversion by sorting map keys, preventing file collisions by incorporating namespaces into output filenames, decoupling standard input (Stdin) for better testability, using filepath.WalkDir for more efficient directory traversal, and adding defensive nil checks within the ResourceCache methods.

Comment thread pkg/migrate/migrate.go Outdated
Comment on lines +143 to +149
for kind, nsMap := range m.cache.resources {
converter, registered := m.converters[kind]
if !registered {
continue
}

for _, res := range nsMap {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since map iteration in Go is randomized, iterating over m.cache.resources directly makes the resource conversion order non-deterministic. This results in unpredictable log ordering and output generation. Sort the kinds and resource keys alphabetically to ensure deterministic execution.

	kinds := make([]string, 0, len(m.cache.resources))
	for kind := range m.cache.resources {
		kinds = append(kinds, kind)
	}
	sort.Strings(kinds)

	for _, kind := range kinds {
		converter, registered := m.converters[kind]
		if !registered {
			continue
		}

		nsMap := m.cache.resources[kind]
		keys := make([]string, 0, len(nsMap))
		for key := range nsMap {
			keys = append(keys, key)
		}
		sort.Strings(keys)

		for _, key := range keys {
			res := nsMap[key]
References
  1. Adhering to the repository style guide by proposing code changes using the GitHub suggestion block format. (link)

Comment thread pkg/migrate/migrate.go Outdated
Comment on lines +230 to +232
for _, out := range outputs {
filename := fmt.Sprintf("%s-%s.yaml", strings.ToLower(out.GetKind()), strings.ToLower(out.GetName()))
fp := filepath.Join(outputDir, filename)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Kubernetes, namespaced resources (e.g., Service, PodMonitor) can have the same name across different namespaces. Writing them to files using only Kind and Name will cause resources in different namespaces to silently overwrite each other. Include the namespace in the filename for namespaced resources to prevent collisions.

	for _, out := range outputs {
		var filename string
		if ns := out.GetNamespace(); ns != "" {
			filename = fmt.Sprintf("%s-%s-%s.yaml", strings.ToLower(out.GetKind()), strings.ToLower(ns), strings.ToLower(out.GetName()))
		} else {
			filename = fmt.Sprintf("%s-%s.yaml", strings.ToLower(out.GetKind()), strings.ToLower(out.GetName()))
		}
		fp := filepath.Join(outputDir, filename)
References
  1. Adhering to the repository style guide by proposing code changes using the GitHub suggestion block format. (link)

Comment thread pkg/migrate/migrate.go
Comment on lines +3 to +14
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
k8syaml "k8s.io/apimachinery/pkg/util/yaml"
"sigs.k8s.io/yaml"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To support deterministic sorting of resources during conversion, import the standard sort package.

Suggested change
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
k8syaml "k8s.io/apimachinery/pkg/util/yaml"
"sigs.k8s.io/yaml"
)
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
k8syaml "k8s.io/apimachinery/pkg/util/yaml"
"sigs.k8s.io/yaml"
)
References
  1. Adhering to the repository style guide by proposing code changes using the GitHub suggestion block format. (link)

Comment thread pkg/migrate/migrate.go Outdated
Comment on lines +29 to +32
// Decoupled output streams (defaults to os.Stdout/os.Stderr)
Stdout io.Writer
Stderr io.Writer
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To allow full testability and consistency with Stdout and Stderr, decouple the input stream by adding a Stdin field to the Migrator struct.

Suggested change
// Decoupled output streams (defaults to os.Stdout/os.Stderr)
Stdout io.Writer
Stderr io.Writer
}
// Decoupled streams (defaults to os.Stdin/os.Stdout/os.Stderr)
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
References
  1. Adhering to the repository style guide by proposing code changes using the GitHub suggestion block format. (link)

Comment thread pkg/migrate/migrate.go
Comment on lines +35 to +41
func NewMigrator() *Migrator {
return &Migrator{
converters: make(map[string]ResourceConverter),
cache: NewResourceCache(),
Stdout: os.Stdout,
Stderr: os.Stderr,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Initialize the new Stdin field to os.Stdin by default in NewMigrator.

Suggested change
func NewMigrator() *Migrator {
return &Migrator{
converters: make(map[string]ResourceConverter),
cache: NewResourceCache(),
Stdout: os.Stdout,
Stderr: os.Stderr,
}
func NewMigrator() *Migrator {
return &Migrator{
converters: make(map[string]ResourceConverter),
cache: NewResourceCache(),
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
}
References
  1. Adhering to the repository style guide by proposing code changes using the GitHub suggestion block format. (link)

Comment thread pkg/migrate/migrate.go
Comment on lines +77 to +79
if path == "-" {
return m.parseYAMLStream(os.Stdin)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the decoupled m.Stdin stream instead of hardcoding os.Stdin when reading from standard input.

Suggested change
if path == "-" {
return m.parseYAMLStream(os.Stdin)
}
if path == "-" {
return m.parseYAMLStream(m.Stdin)
}
References
  1. Adhering to the repository style guide by proposing code changes using the GitHub suggestion block format. (link)

Comment thread pkg/migrate/migrate.go Outdated
Comment on lines +90 to +96
return filepath.Walk(path, func(fp string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if fi.IsDir() {
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use filepath.WalkDir instead of filepath.Walk for directory traversal. filepath.WalkDir is significantly more efficient because it avoids calling os.Lstat on every file and directory.

Suggested change
return filepath.Walk(path, func(fp string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if fi.IsDir() {
return nil
}
return filepath.WalkDir(path, func(fp string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
References
  1. Adhering to the repository style guide by proposing code changes using the GitHub suggestion block format. (link)

Comment thread pkg/migrate/types.go
Comment on lines +58 to +63
// Add adds a resource to the cache.
func (c *ResourceCache) Add(u *unstructured.Unstructured) {
kind := u.GetKind()
if _, ok := c.resources[kind]; !ok {
c.resources[kind] = make(map[string]*unstructured.Unstructured)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a defensive check to prevent nil pointer dereference if u is nil. Also, lazily initialize c.resources if the cache was instantiated directly without using NewResourceCache.

Suggested change
// Add adds a resource to the cache.
func (c *ResourceCache) Add(u *unstructured.Unstructured) {
kind := u.GetKind()
if _, ok := c.resources[kind]; !ok {
c.resources[kind] = make(map[string]*unstructured.Unstructured)
}
// Add adds a resource to the cache.
func (c *ResourceCache) Add(u *unstructured.Unstructured) {
if u == nil {
return
}
if c.resources == nil {
c.resources = make(map[string]map[string]*unstructured.Unstructured)
}
kind := u.GetKind()
if _, ok := c.resources[kind]; !ok {
c.resources[kind] = make(map[string]*unstructured.Unstructured)
}
References
  1. Adhering to the repository style guide by proposing code changes using the GitHub suggestion block format. (link)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'd rather panic in this case, we should never be adding nil, and also are expected to use the constructor

Comment thread pkg/migrate/types.go
Comment on lines +74 to +79
// Get retrieves a resource from the cache by kind, namespace, and name.
func (c *ResourceCache) Get(kind, namespace, name string) (*unstructured.Unstructured, bool) {
nsMap, ok := c.resources[kind]
if !ok {
return nil, false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a defensive check to prevent a nil pointer dereference or panic if c.resources is nil (e.g., if the cache was instantiated directly).

Suggested change
// Get retrieves a resource from the cache by kind, namespace, and name.
func (c *ResourceCache) Get(kind, namespace, name string) (*unstructured.Unstructured, bool) {
nsMap, ok := c.resources[kind]
if !ok {
return nil, false
}
// Get retrieves a resource from the cache by kind, namespace, and name.
func (c *ResourceCache) Get(kind, namespace, name string) (*unstructured.Unstructured, bool) {
if c.resources == nil {
return nil, false
}
nsMap, ok := c.resources[kind]
if !ok {
return nil, false
}
References
  1. Adhering to the repository style guide by proposing code changes using the GitHub suggestion block format. (link)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'd rather panic in this case, we are expected to use the constructor

@karthunni karthunni force-pushed the karthunni/podmonitor-migration branch from 9857444 to 4976085 Compare June 17, 2026 19:22
@karthunni karthunni force-pushed the karthunni/podmonitor-migration branch from 1de300d to 036f215 Compare June 17, 2026 19:30
@karthunni

Copy link
Copy Markdown
Collaborator Author

/gemini review

@karthunni karthunni requested a review from bernot-dev June 17, 2026 19:39

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new migration tool, gmp-migrate, designed to translate Prometheus Operator configurations to Google Managed Prometheus (GMP). It includes a Dockerfile, a CLI entry point, core migration orchestration logic, and a resource caching mechanism. The review feedback highlights several critical improvements to enhance the tool's robustness and safety: ensuring the CLI exits with a non-zero code when individual resource migrations fail, making file extension checks case-insensitive, skipping nameless resources to avoid cache collisions, deep-copying cached resources before conversion to prevent unintended mutations, and adding nil-safety checks to the resource cache.

Comment thread cmd/gmp-migrate/main.go
Comment on lines +34 to +39
migrator := migrate.NewMigrator()
_, err := migrator.Run(*inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "[ERROR] Migration failed: %v\n", err)
os.Exit(1)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If some resources fail to migrate, report.FailedCount is incremented, but migrator.Run still returns a nil error. As a result, the CLI exits with code 0. This is unsafe for CI/CD automation pipelines, which rely on non-zero exit codes to detect failures. The CLI should check report.FailedCount > 0 and exit with a non-zero code.

Suggested change
migrator := migrate.NewMigrator()
_, err := migrator.Run(*inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "[ERROR] Migration failed: %v\n", err)
os.Exit(1)
}
migrator := migrate.NewMigrator()
report, err := migrator.Run(*inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "[ERROR] Migration failed: %v\n", err)
os.Exit(1)
}
if report.FailedCount > 0 {
fmt.Fprintf(os.Stderr, "[ERROR] Migration completed with %d failed resource(s)\n", report.FailedCount)
os.Exit(1)
}
	migrator := migrate.NewMigrator()
	report, err := migrator.Run(*inputFile)
	if err != nil {
		fmt.Fprintf(os.Stderr, "[ERROR] Migration failed: %v\n", err)
		os.Exit(1)
	}
	if report.FailedCount > 0 {
		fmt.Fprintf(os.Stderr, "[ERROR] Migration completed with %d failed resource(s)\n", report.FailedCount)
		os.Exit(1)
}
References
  1. When proposing a code change, always use the GitHub 'suggestion' Markdown block. (link)

Comment thread pkg/migrate/migrate.go
if d.IsDir() {
return nil
}
if strings.HasSuffix(fp, ".yaml") || strings.HasSuffix(fp, ".yml") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The file extension check is currently case-sensitive. If a user has files with uppercase extensions (e.g., .YAML or .Yml), they will be silently skipped during the migration. Using filepath.Ext and strings.ToLower makes the file discovery much more robust.

Suggested change
if strings.HasSuffix(fp, ".yaml") || strings.HasSuffix(fp, ".yml") {
ext := strings.ToLower(filepath.Ext(fp))
if ext == ".yaml" || ext == ".yml" {
		ext := strings.ToLower(filepath.Ext(fp))
		if ext == ".yaml" || ext == ".yml" {
References
  1. When proposing a code change, always use the GitHub 'suggestion' Markdown block. (link)

Comment thread pkg/migrate/migrate.go
Comment on lines +151 to +153
if u.Object == nil || u.GetKind() == "" {
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The parser currently checks for u.GetKind() == "" but does not check for u.GetName() == "". If an invalid or incomplete resource without a name is parsed, it will be added to the cache and will overwrite other nameless resources under the key "default/". We should skip resources that do not have a name.

Suggested change
if u.Object == nil || u.GetKind() == "" {
continue
}
if u.Object == nil || u.GetKind() == "" || u.GetName() == "" {
continue
}
Suggested change
if u.Object == nil || u.GetKind() == "" {
continue
}
if u.Object == nil || u.GetKind() == "" || u.GetName() == "" {
continue
}
References
  1. When proposing a code change, always use the GitHub 'suggestion' Markdown block. (link)

Comment thread pkg/migrate/migrate.go
Comment on lines +179 to +180
res := nsMap[key]
outputs, logs, err := converter.Convert(res, m.cache)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The cached resource res is passed directly to converter.Convert(res, m.cache). Since unstructured.Unstructured is a pointer type containing nested maps, any modification made by the converter will mutate the cached resource. This can lead to non-deterministic behavior or bugs if other converters look up the same resource later. Passing a deep copy of the resource ensures the cache remains immutable and safe.

Suggested change
res := nsMap[key]
outputs, logs, err := converter.Convert(res, m.cache)
res := nsMap[key].DeepCopy()
outputs, logs, err := converter.Convert(res, m.cache)
Suggested change
res := nsMap[key]
outputs, logs, err := converter.Convert(res, m.cache)
res := nsMap[key].DeepCopy()
outputs, logs, err := converter.Convert(res, m.cache)
References
  1. When proposing a code change, always use the GitHub 'suggestion' Markdown block. (link)

Comment thread pkg/migrate/types.go
Comment on lines +73 to +77
func (c *ResourceCache) Add(u *unstructured.Unstructured) {
kind := u.GetKind()
if _, ok := c.resources[kind]; !ok {
c.resources[kind] = make(map[string]*unstructured.Unstructured)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential nil pointer dereferences and map assignment panics, we should add a nil check for u and lazily initialize c.resources if the ResourceCache was instantiated as a zero value (e.g., var cache ResourceCache).

Suggested change
func (c *ResourceCache) Add(u *unstructured.Unstructured) {
kind := u.GetKind()
if _, ok := c.resources[kind]; !ok {
c.resources[kind] = make(map[string]*unstructured.Unstructured)
}
func (c *ResourceCache) Add(u *unstructured.Unstructured) {
if u == nil {
return
}
if c.resources == nil {
c.resources = make(map[string]map[string]*unstructured.Unstructured)
}
kind := u.GetKind()
if _, ok := c.resources[kind]; !ok {
c.resources[kind] = make(map[string]*unstructured.Unstructured)
}
Suggested change
func (c *ResourceCache) Add(u *unstructured.Unstructured) {
kind := u.GetKind()
if _, ok := c.resources[kind]; !ok {
c.resources[kind] = make(map[string]*unstructured.Unstructured)
}
func (c *ResourceCache) Add(u *unstructured.Unstructured) {
if u == nil {
return
}
if c.resources == nil {
c.resources = make(map[string]map[string]*unstructured.Unstructured)
}
kind := u.GetKind()
if _, ok := c.resources[kind]; !ok {
c.resources[kind] = make(map[string]*unstructured.Unstructured)
}
References
  1. When proposing a code change, always use the GitHub 'suggestion' Markdown block. (link)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant