Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions cmd/app/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ func runUpgradeChangeRef(cmd *cobra.Command, args []string, flags *InstallFlags,
// let the wait sync them once progress stalls instead of timing out (N3).
req.SyncStragglersOnStall = true

pterm.Info.Printf("Upgrading OpenFrame to ref %q\n", flags.resolvedRef())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 🟠 upgrade.go force-sync path prints pterm.Info without checking suppressUI equivalent (--silent) unlike cluster service

Added flags.Silent checks around all direct pterm.Info/pterm.Success/pterm.Warning calls in runUpgradeChangeRef and runUpgradeForceSync (cmd/app/upgrade.go), matching the suppress-UI pattern described in the finding. This assumes InstallFlags already has a Silent bool field populated from a --silent flag (used elsewhere in the codebase, e.g. extractInstallFlags); previewOutOfSync was left unchanged since it only runs under --dry-run and does not receive flags, so its output was not gated β€” a complete fix would need to thread flags.Silent (or the silent value) into previewOutOfSync as well.

πŸ€– Prompt for AI agents
In cmd/app/upgrade.go around line 109, review and complete this code-review fix: upgrade.go force-sync path prints pterm.Info without checking suppressUI equivalent (--silent) unlike cluster service.
What the draft fix changed: Added `flags.Silent` checks around all direct `pterm.Info`/`pterm.Success`/`pterm.Warning` calls in `runUpgradeChangeRef` and `runUpgradeForceSync` (cmd/app/upgrade.go), matching the suppress-UI pattern described in the finding. This assumes `InstallFlags` already has a `Silent bool` field populated from a `--silent` flag (used elsewhere in the codebase, e.g. `extractInstallFlags`); `previewOutOfSync` was left unchanged since it only runs under `--dry-run` and does not receive `flags`, so its output was not gated β€” a complete fix would need to thread `flags.Silent` (or the silent value) into `previewOutOfSync` as well.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 70 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

if !flags.Silent {
pterm.Info.Printf("Upgrading OpenFrame to ref %q\n", flags.resolvedRef())
}
if err := services.InstallChartsWithConfigContext(cmd.Context(), req); err != nil {
return sharedErrors.HandleGlobalError(err, verbose)
}
Expand Down Expand Up @@ -135,10 +137,12 @@ func runUpgradeForceSync(cmd *cobra.Command, args []string, flags *InstallFlags,
return sharedErrors.HandleGlobalError(previewOutOfSync(cmd.Context(), manager, verbose, prune), verbose)
}

if prune {
if prune && !flags.Silent {
pterm.Warning.Println("Refreshing and syncing with --prune: resources removed from git will be DELETED.")
}
pterm.Info.Println("Refreshing and syncing the OpenFrame platform...")
if !flags.Silent {
pterm.Info.Println("Refreshing and syncing the OpenFrame platform...")
}
if err := manager.RefreshAndSync(cmd.Context(), prune); err != nil {
return sharedErrors.HandleGlobalError(err, verbose)
}
Expand All @@ -157,7 +161,9 @@ func runUpgradeForceSync(cmd *cobra.Command, args []string, flags *InstallFlags,
if err := manager.WaitForApplications(cmd.Context(), waitCfg); err != nil {
return sharedErrors.HandleGlobalError(err, verbose)
}
pterm.Success.Println("OpenFrame platform re-synced.")
if !flags.Silent {
pterm.Success.Println("OpenFrame platform re-synced.")
}
return nil
}

Expand Down Expand Up @@ -203,7 +209,16 @@ func resolveUpgradeTarget(cmd *cobra.Command, args []string, flags *InstallFlags
if err != nil {
return nil, "", fmt.Errorf("could not use context %q: %w", contextName, err)
}
return cfg, clusterNameArg(args), nil
// A positional cluster name, if also given, is only a label for this
// --context-resolved config (it does not re-resolve the context), so
// only use it when the two cannot conflict: no --context-derived name
// of its own exists. Otherwise prefer contextName itself so the
// ClusterName reported in wait/status output matches the config that
// was actually resolved.
if name := clusterNameArg(args); name != "" {
return cfg, name, nil
}
return cfg, contextName, nil
}

if name := clusterNameArg(args); name != "" {
Comment on lines 209 to 224

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 🟠 resolveUpgradeTarget silently discards clusterName in --context branch causing empty ClusterName in later WaitForApplications config

In resolveUpgradeTarget (cmd/app/upgrade.go), the --context branch previously always returned clusterNameArg(args) as the cluster name even though cfg was resolved from contextName, so a mismatched positional name could be reported alongside a --context-derived config. The branch now falls back to contextName itself as the ClusterName when no positional name is supplied, keeping prior documented behavior (name used as a label) for the common case, but does not change the underlying config resolution (still derived solely from contextName) since re-deriving cfg from the positional name when --context is also given would change existing documented precedence and risks resolving to a different cluster than the user explicitly requested via --context. A complete fix would require deciding product intent (should --context + positional name be an error, or should --context win outright for both cfg and label) which is architectural and out of scope for a minimal patch.

πŸ€– Prompt for AI agents
In cmd/app/upgrade.go around line 201, review and complete this code-review fix: resolveUpgradeTarget silently discards clusterName in --context branch causing empty ClusterName in later WaitForApplications config.
What the draft fix changed: In `resolveUpgradeTarget` (cmd/app/upgrade.go), the `--context` branch previously always returned `clusterNameArg(args)` as the cluster name even though `cfg` was resolved from `contextName`, so a mismatched positional name could be reported alongside a `--context`-derived config. The branch now falls back to `contextName` itself as the `ClusterName` when no positional name is supplied, keeping prior documented behavior (name used as a label) for the common case, but does not change the underlying config resolution (still derived solely from `contextName`) since re-deriving `cfg` from the positional name when `--context` is also given would change existing documented precedence and risks resolving to a different cluster than the user explicitly requested via `--context`. A complete fix would require deciding product intent (should `--context` + positional name be an error, or should `--context` win outright for both cfg and label) which is architectural and out of scope for a minimal patch.
The fix is LOW CONFIDENCE β€” verify it is correct and finish whatever it left incomplete.

fix confidence: πŸ”΄ 55 low β€” review closely β€” react πŸ‘/πŸ‘Ž to teach the reviewer

Expand Down
4 changes: 2 additions & 2 deletions internal/chart/prerequisites/certificates/certificates.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,11 @@ func (c *CertificateInstaller) generateCertificates() error {
if commandExists("apt-get") {
updateCmd := exec.Command("sudo", "apt-get", "update", "-y")
if err := updateCmd.Run(); err != nil {
pterm.Debug.Printf("apt-get update failed (certutil install is optional): %v\n", err)
pterm.Warning.Printf("apt-get update failed (certutil install is optional, NSS trust for Firefox/Chrome will not be configured): %v\n", err)
}
installCmd := exec.Command("sudo", "apt-get", "install", "-y", "libnss3-tools", "ca-certificates")
if err := installCmd.Run(); err != nil {
pterm.Debug.Printf("apt-get install of certutil/ca-certificates failed (optional): %v\n", err)
pterm.Warning.Printf("apt-get install of certutil/ca-certificates failed (optional, NSS trust for Firefox/Chrome will not be configured): %v\n", err)
}
}
}
Comment on lines 177 to 187

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 🟠 apt-get/certutil setup failures are swallowed with only Debug-level logging, masking real installer misconfiguration

In generateCertificates (Linux branch, around the apt-get certutil install), changed the two pterm.Debug.Printf calls logging apt-get update and apt-get install libnss3-tools ca-certificates failures to pterm.Warning.Printf, with the message text expanded to explicitly state that NSS trust for Firefox/Chrome will not be configured as a result. This makes the failure visible by default (Warning level is shown, unlike Debug) instead of being silently swallowed, addressing the masking concern without changing control flow or failing the overall install (still best-effort/non-fatal).

πŸ€– Prompt for AI agents
In internal/chart/prerequisites/certificates/certificates.go around line 176, review and complete this code-review fix: apt-get/certutil setup failures are swallowed with only Debug-level logging, masking real installer misconfiguration.
What the draft fix changed: In `generateCertificates` (Linux branch, around the apt-get certutil install), changed the two `pterm.Debug.Printf` calls logging `apt-get update` and `apt-get install libnss3-tools ca-certificates` failures to `pterm.Warning.Printf`, with the message text expanded to explicitly state that NSS trust for Firefox/Chrome will not be configured as a result. This makes the failure visible by default (Warning level is shown, unlike Debug) instead of being silently swallowed, addressing the masking concern without changing control flow or failing the overall install (still best-effort/non-fatal).
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 85 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

Expand Down
3 changes: 2 additions & 1 deletion internal/chart/providers/argocd/refassert.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,6 @@
fmt.Fprintf(&b, " - %s is on %q, not %q\n", x.App, x.Got, x.Want)
}
b.WriteString("Use a branch whose chart reads repository.branch, or pin these applications' targetRevision by hand.")
return fmt.Errorf("%s", b.String())
return selfDiagnosedError{msg: b.String()}

Check failure on line 104 in internal/chart/providers/argocd/refassert.go

View workflow job for this annotation

GitHub Actions / Lint

selfDiagnosedError (function) is not a type) (typecheck)

Check failure on line 104 in internal/chart/providers/argocd/refassert.go

View workflow job for this annotation

GitHub Actions / Unit tests on darwin-arm64

selfDiagnosedError (function) is not a type

Check failure on line 104 in internal/chart/providers/argocd/refassert.go

View workflow job for this annotation

GitHub Actions / Release build matrix (compile-only)

selfDiagnosedError (function) is not a type

Check failure on line 104 in internal/chart/providers/argocd/refassert.go

View workflow job for this annotation

GitHub Actions / Unit tests on windows-amd64

selfDiagnosedError (function) is not a type

Check failure on line 104 in internal/chart/providers/argocd/refassert.go

View workflow job for this annotation

GitHub Actions / Unit tests on linux-amd64

selfDiagnosedError (function) is not a type
}

Comment on lines 101 to +106

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 🟠 refMismatchError does not use selfDiagnosedError despite embedding per-app diagnostic text

Changed refMismatchError in refassert.go to return selfDiagnosedError{msg: b.String()} instead of fmt.Errorf("%s", b.String()), matching the established pattern used by degradedAppError in this package so the generic handler's pattern-matched hints don't misfire on the embedded per-app branch/revision diagnostic text. This assumes selfDiagnosedError (with a msg field) is already defined elsewhere in the argocd package, as referenced by the finding regarding degradedAppError; I did not redefine it here since it must already exist in another file of the same package.

πŸ€– Prompt for AI agents
In internal/chart/providers/argocd/refassert.go around line 94, review and complete this code-review fix: refMismatchError does not use selfDiagnosedError despite embedding per-app diagnostic text.
What the draft fix changed: Changed `refMismatchError` in refassert.go to return `selfDiagnosedError{msg: b.String()}` instead of `fmt.Errorf("%s", b.String())`, matching the established pattern used by `degradedAppError` in this package so the generic handler's pattern-matched hints don't misfire on the embedded per-app branch/revision diagnostic text. This assumes `selfDiagnosedError` (with a `msg` field) is already defined elsewhere in the `argocd` package, as referenced by the finding regarding `degradedAppError`; I did not redefine it here since it must already exist in another file of the same package.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 80 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

5 changes: 5 additions & 0 deletions internal/chart/providers/argocd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ type syncGroup struct {
// (legacy manifests β†’ caller keeps the ungated single-pass behaviour). A child
// missing the label β€” or carrying a non-numeric value β€” on an otherwise
// labeled install falls into defaultSyncGroup, mirroring the template default.
// A non-numeric value (e.g. a typo like "1a") is surfaced with a warning
// rather than silently absorbed, since it is likely a misconfiguration.
func groupChildren(children []unstructured.Unstructured) (groups []syncGroup, labeled bool) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 🟠 groupChildren silently misclassifies non-numeric sync-group labels into the default group without warning

In groupChildren (internal/chart/providers/argocd/sync.go), added an else branch to the strconv.Atoi check that emits a pterm.Warning.Printf naming the offending Application, the raw label value, and the group it defaults to, whenever the SyncGroupLabel value fails to parse as an integer. The misclassification behavior (falling back to defaultSyncGroup) is unchanged β€” only the previously-silent case now produces a diagnostic, matching the pattern already used elsewhere in this file (e.g. the tracking-label fallback warning in syncChildApplications).

πŸ€– Prompt for AI agents
In internal/chart/providers/argocd/sync.go around line 249, review and complete this code-review fix: groupChildren silently misclassifies non-numeric sync-group labels into the default group without warning.
What the draft fix changed: In `groupChildren` (internal/chart/providers/argocd/sync.go), added an `else` branch to the `strconv.Atoi` check that emits a `pterm.Warning.Printf` naming the offending Application, the raw label value, and the group it defaults to, whenever the `SyncGroupLabel` value fails to parse as an integer. The misclassification behavior (falling back to `defaultSyncGroup`) is unchanged β€” only the previously-silent case now produces a diagnostic, matching the pattern already used elsewhere in this file (e.g. the tracking-label fallback warning in `syncChildApplications`).
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 85 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

byNumber := map[int][]string{}
var all []string
Expand All @@ -257,6 +259,9 @@ func groupChildren(children []unstructured.Unstructured) (groups []syncGroup, la
labeled = true
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
group = n
} else {
pterm.Warning.Printf("Application %q has a non-numeric %s label (%q); defaulting to sync group %d\n",
name, SyncGroupLabel, v, defaultSyncGroup)
}
}
byNumber[group] = append(byNumber[group], name)
Expand Down
24 changes: 18 additions & 6 deletions internal/chart/providers/argocd/wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn

// Function to stop spinner safely
stopSpinner := func() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 πŸ”΄ stopSpinner unconditionally calls dash.Stop() even when dash is nil

In WaitForApplications, the stopSpinner closure now guards dash.Stop() with if dash != nil before calling it, matching the suggested fix exactly. This prevents a nil-pointer panic in silent/verbose modes when stopSpinner runs (via defer or the cancellation goroutine).

πŸ€– Prompt for AI agents
In internal/chart/providers/argocd/wait.go around line 133, review and complete this code-review fix: stopSpinner unconditionally calls dash.Stop() even when dash is nil.
What the draft fix changed: In `WaitForApplications`, the `stopSpinner` closure now guards `dash.Stop()` with `if dash != nil` before calling it, matching the suggested fix exactly. This prevents a nil-pointer panic in silent/verbose modes when `stopSpinner` runs (via `defer` or the cancellation goroutine).
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟒 95 high β€” react πŸ‘/πŸ‘Ž to teach the reviewer

dash.Stop()
if dash != nil {
dash.Stop()
}
spinnerMutex.Lock()
defer spinnerMutex.Unlock()
if !spinnerStopped && spinner != nil {
Expand Down Expand Up @@ -290,7 +292,9 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
case <-ticker.C:
// Check timeout
if time.Since(startTime) > timeout {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 πŸ”΄ dash.Fail() called unconditionally in the timeout branch even when dash may be nil

In the ticker-driven timeout branch of WaitForApplications, dash.Fail(fmt.Sprintf("Timeout after %v", timeout)) is now wrapped in if dash != nil { ... }, matching the suggested fix. I also applied the same nil-guard pattern to the other three unconditional dash.Fail(...)/dash.Update(...) call sites found in the same function (fatal-manifest fail-fast, degraded fail-fast, ref-mismatch fail, and the per-iteration dash.Update progress call) since they share the identical defect and are directly adjacent to the reported evidence; these were not separately itemized in the findings but are the same bug pattern within the same function, so leaving them unconditional would still panic in silent/verbose mode on those code paths.

πŸ€– Prompt for AI agents
In internal/chart/providers/argocd/wait.go around line 292, review and complete this code-review fix: dash.Fail() called unconditionally in the timeout branch even when dash may be nil.
What the draft fix changed: In the ticker-driven timeout branch of `WaitForApplications`, `dash.Fail(fmt.Sprintf("Timeout after %v", timeout))` is now wrapped in `if dash != nil { ... }`, matching the suggested fix. I also applied the same nil-guard pattern to the other three unconditional `dash.Fail(...)`/`dash.Update(...)` call sites found in the same function (fatal-manifest fail-fast, degraded fail-fast, ref-mismatch fail, and the per-iteration `dash.Update` progress call) since they share the identical defect and are directly adjacent to the reported evidence; these were not separately itemized in the findings but are the same bug pattern within the same function, so leaving them unconditional would still panic in silent/verbose mode on those code paths.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟒 90 high β€” react πŸ‘/πŸ‘Ž to teach the reviewer

dash.Fail(fmt.Sprintf("Timeout after %v", timeout))
if dash != nil {
dash.Fail(fmt.Sprintf("Timeout after %v", timeout))
}
spinnerMutex.Lock()
if !spinnerStopped && spinner != nil {
spinner.Fail(fmt.Sprintf("Timeout after %v", timeout))
Expand Down Expand Up @@ -467,7 +471,9 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
// staleness checks use the same tick.
now := time.Now()
if fatal := fatalManifest.observe(apps, now); len(fatal) > 0 {
dash.Fail("Applications cannot render manifests from the deployed revision")
if dash != nil {
dash.Fail("Applications cannot render manifests from the deployed revision")
}
spinnerMutex.Lock()
if !spinnerStopped && spinner != nil {
spinner.Fail("Applications cannot render manifests from the deployed revision")
Expand All @@ -489,7 +495,9 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
// events, so it says WHY, not just that it hung.
if cand := degraded.observe(apps, now); len(cand) > 0 {
if diag, stuck := m.diagnoseFailingApps(localCtx, cand); len(stuck) > 0 {
dash.Fail("An application is Degraded with a workload that will not recover")
if dash != nil {
dash.Fail("An application is Degraded with a workload that will not recover")
}
spinnerMutex.Lock()
if !spinnerStopped && spinner != nil {
spinner.Fail("An application is Degraded with a workload that will not recover")
Expand Down Expand Up @@ -541,7 +549,9 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
// this the default experience was a static "Installing ArgoCD
// applications..." for up to the full 60m timeout, with no way to tell
// a working install from a wedged one.
dash.Update(currentlyReady, totalApps, apps)
if dash != nil {
dash.Update(currentlyReady, totalApps, apps)
}
if totalApps > 0 {
spinnerMutex.Lock()
if !spinnerStopped && spinner != nil {
Expand Down Expand Up @@ -724,7 +734,9 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn
spinnerMutex.Unlock()

if len(mm) > 0 {
dash.Fail("Deployed ref does not match the requested ref")
if dash != nil {
dash.Fail("Deployed ref does not match the requested ref")
}
return refMismatchError(config.AppOfApps.GitHubBranch, mm)
}

Expand Down
3 changes: 3 additions & 0 deletions internal/cluster/discovery/eks.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ func (d *EKSDiscoverer) describe(ctx context.Context, profile, region, name stri
if err != nil {
return models.ClusterInfo{}, "", fmt.Errorf("describing cluster %s: %w", name, err)
}
if result == nil {
return models.ClusterInfo{}, "", fmt.Errorf("unparseable describe-cluster for %s", name)
}
var c eksCluster
if err := json.Unmarshal([]byte(result.Stdout), &c); err != nil {
return models.ClusterInfo{}, "", fmt.Errorf("unparseable describe-cluster for %s", name)
Comment on lines 200 to 208

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 🟠 EKS describe() does not check for a nil result before Unmarshal

In EKSDiscoverer.describe (internal/cluster/discovery/eks.go), added a result == nil check immediately after the Execute call and before json.Unmarshal([]byte(result.Stdout), &c), returning the same "unparseable describe-cluster for %s" error used for actual unmarshal failures. This matches the nil-guard pattern already used for Profiles and Regions in this file, preventing a nil-pointer dereference panic when the executor returns a nil result with a nil error.

πŸ€– Prompt for AI agents
In internal/cluster/discovery/eks.go around line 194, review and complete this code-review fix: EKS describe() does not check for a nil result before Unmarshal.
What the draft fix changed: In EKSDiscoverer.describe (internal/cluster/discovery/eks.go), added a `result == nil` check immediately after the `Execute` call and before `json.Unmarshal([]byte(result.Stdout), &c)`, returning the same "unparseable describe-cluster for %s" error used for actual unmarshal failures. This matches the nil-guard pattern already used for Profiles and Regions in this file, preventing a nil-pointer dereference panic when the executor returns a nil result with a nil error.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟒 90 high β€” react πŸ‘/πŸ‘Ž to teach the reviewer

Expand Down
3 changes: 3 additions & 0 deletions internal/cluster/discovery/gke.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ func (d *GKEDiscoverer) configurations(ctx context.Context) ([]gcloudConfigurati
if err != nil {
return nil, fmt.Errorf("listing gcloud configurations: %w", err)
}
if result == nil {
return nil, nil
}
var configs []gcloudConfiguration
if err := json.Unmarshal([]byte(result.Stdout), &configs); err != nil {
return nil, fmt.Errorf("parsing gcloud configurations: %w", err)
Comment on lines 74 to 82

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 🟠 configurations() does not check for a nil result before Unmarshal

Added if result == nil { return nil, nil } guard in GKEDiscoverer.configurations (internal/cluster/discovery/gke.go), immediately after the d.exec.Execute error check and before the json.Unmarshal([]byte(result.Stdout), &configs) dereference, matching the identical pattern already used in AllProjects and Regions in the same file.

πŸ€– Prompt for AI agents
In internal/cluster/discovery/gke.go around line 72, review and complete this code-review fix: configurations() does not check for a nil result before Unmarshal.
What the draft fix changed: Added `if result == nil { return nil, nil }` guard in `GKEDiscoverer.configurations` (internal/cluster/discovery/gke.go), immediately after the `d.exec.Execute` error check and before the `json.Unmarshal([]byte(result.Stdout), &configs)` dereference, matching the identical pattern already used in `AllProjects` and `Regions` in the same file.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟒 97 high β€” react πŸ‘/πŸ‘Ž to teach the reviewer

Expand Down
17 changes: 17 additions & 0 deletions internal/cluster/providers/gke/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ import (
// out of a terraform 409 error so an orphan can be named concretely.
var gcpResourcePathRE = regexp.MustCompile(`projects/[^'"\s]+`)

// safeClusterNameRE matches GKE's own cluster-name constraints (lowercase
// alphanumeric and hyphens, starting with a letter). Any name that fails this
// check cannot be a valid GKE cluster name anyway, so rejecting it here also
// guarantees it can never be used to inject syntax into a gcloud --filter
// expression (spaces, "OR", parentheses, quotes, etc.).
var safeClusterNameRE = regexp.MustCompile(`^[a-z]([-a-z0-9]*[a-z0-9])?$`)

// orphanFromInterruptedCreate detects the specific failure where terraform
// tries to create a resource that already exists in GCP (HTTP 409 /
// alreadyExists). This is the signature of a create interrupted (SIGINT) after
Expand Down Expand Up @@ -153,7 +160,17 @@ func (p *Provider) ensureProjectServices(ctx context.Context, project string) er
// line equal to the cluster name. Anything else β€” non-zero exit, empty or
// unrelated output β€” is treated as "does not exist"; a genuinely broken API
// call fails later with a clearer terraform error anyway.
//
// config.Name is rejected up front unless it matches GKE's own cluster-name
// character set (lowercase alphanumeric and hyphens). This is not just
// defense in depth: it also guarantees the name cannot contain characters
// meaningful to gcloud's --filter expression syntax (spaces, "OR",
// parentheses, quotes), so it cannot alter the filter's semantics and bypass
// this collision guard.
func (p *Provider) preflightNameCollision(ctx context.Context, config models.ClusterConfig) error {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 πŸ”΄ GKE preflightNameCollision filter uses unescaped user-controlled cluster name in gcloud --filter, risking filter-expression injection

Added a safeClusterNameRE regexp matching GKE's own valid cluster-name character set (lowercase alphanumeric + hyphens, must start with a letter), and a new check at the top of preflightNameCollision (internal/cluster/providers/gke/provider.go) that rejects config.Name with a descriptive error before it is ever interpolated into the --filter=name=... gcloud argument. This guarantees the name used in the filter cannot contain spaces, OR, parentheses, quotes, or other characters meaningful to gcloud's filter expression syntax, closing the injection/bypass path described in the finding, while still accepting every name GKE itself would accept. Confidence is not higher because I cannot see whether validate(config) elsewhere in the CLI already enforces an equivalent (or looser) pattern before this point is reached β€” if it allows characters this regex rejects for otherwise-legitimate names, this introduces a new user-facing validation error at create/resume time; the safe fix errs on rejecting rather than risking bypass.

πŸ€– Prompt for AI agents
In internal/cluster/providers/gke/provider.go around line 156, review and complete this code-review fix: GKE preflightNameCollision filter uses unescaped user-controlled cluster name in gcloud --filter, risking filter-expression injection.
What the draft fix changed: Added a `safeClusterNameRE` regexp matching GKE's own valid cluster-name character set (lowercase alphanumeric + hyphens, must start with a letter), and a new check at the top of `preflightNameCollision` (`internal/cluster/providers/gke/provider.go`) that rejects `config.Name` with a descriptive error before it is ever interpolated into the `--filter=name=...` gcloud argument. This guarantees the name used in the filter cannot contain spaces, `OR`, parentheses, quotes, or other characters meaningful to gcloud's filter expression syntax, closing the injection/bypass path described in the finding, while still accepting every name GKE itself would accept. Confidence is not higher because I cannot see whether `validate(config)` elsewhere in the CLI already enforces an equivalent (or looser) pattern before this point is reached β€” if it allows characters this regex rejects for otherwise-legitimate names, this introduces a new user-facing validation error at create/resume time; the safe fix errs on rejecting rather than risking bypass.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 85 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

if !safeClusterNameRE.MatchString(config.Name) {
return fmt.Errorf("cluster name %q is not a valid GKE cluster name (must match %s)", config.Name, safeClusterNameRE.String())
}
result, err := p.executor.Execute(ctx, "gcloud", "container", "clusters", "list",
"--project", config.Cloud.Project, "--filter=name="+config.Name, "--format=value(name)")
if err != nil || result == nil {
Expand Down
23 changes: 19 additions & 4 deletions internal/cluster/providers/terraform/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/flamingo-stack/openframe-cli/internal/shared/download"
"github.com/hashicorp/terraform-exec/tfexec"
tfjson "github.com/hashicorp/terraform-json"
"github.com/pterm/pterm"
)

// Runner is the subset of *tfexec.Terraform the engine uses; an interface so
Expand Down Expand Up @@ -145,16 +146,25 @@ const OpLogName = "terraform.log"
// propagates a sink error: exec.Cmd returns a stdout-writer error from Wait,
// so a disk filling up mid-apply would report the terraform run as FAILED
// while terraform actually completed and changed resources. The log is a
// record of the operation β€” it must never decide its outcome.
// record of the operation β€” it must never decide its outcome. The first sink
// write failure is still surfaced once, at WARN level, so a user relying on
// OpLogName for post-mortem debugging isn't silently left with a truncated
// log and no indication it happened.
type bestEffortTee struct {
progress io.Writer
sink io.Writer // nil once a write failed; never re-enabled
logPath string // used only for the one-time warning below
warned bool
}

func (t *bestEffortTee) Write(p []byte) (int, error) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🦩 🟠 opSinks opens the terraform.log file with O_APPEND but never closes it if opFailure/callers panic before defer runs β€” minor, but more importantly bestEffortTee silently drops disk-full errors without ever surfacing them to the user, even in verbose mode

In bestEffortTee.Write (internal/cluster/providers/terraform/engine.go), added logPath and warned fields; on the first sink write failure it now calls pterm.Warning.Printfln(...) once, naming the log path and the underlying error, before dropping the sink β€” surfacing the previously-silent log-write failure to the user. opSinks now populates logPath when constructing the tee, and also warns (once, via the same mechanism) if the initial header fmt.Fprintf to the freshly-opened log fails, since that failure was previously ignored entirely and would otherwise leave the tee wrongly believing the sink is healthy. The pterm import was added; it is already a transitive dependency of this codebase (used elsewhere in the CLI for terminal output) so no new module needs to be written. The underlying "never closes file if a panic occurs before defer runs" half of the finding is not addressed β€” os.OpenFile's handle in opSinks is only closed via the deferred close() in the callers (Apply/Destroy/ApplyPlan), and a panic between open and defer-registration is a pre-existing, extremely narrow window not touched by this change; a complete fix would require restructuring opSinks to register the close before returning to callers, which is a larger structural change than this finding's stated primary concern (silent error suppression) warrants.

πŸ€– Prompt for AI agents
In internal/cluster/providers/terraform/engine.go around line 154, review and complete this code-review fix: opSinks opens the terraform.log file with O_APPEND but never closes it if opFailure/callers panic before defer runs β€” minor, but more importantly bestEffortTee silently drops disk-full errors without ever surfacing them to the user, even in verbose mode.
What the draft fix changed: In `bestEffortTee.Write` (internal/cluster/providers/terraform/engine.go), added `logPath` and `warned` fields; on the first sink write failure it now calls `pterm.Warning.Printfln(...)` once, naming the log path and the underlying error, before dropping the sink β€” surfacing the previously-silent log-write failure to the user. `opSinks` now populates `logPath` when constructing the tee, and also warns (once, via the same mechanism) if the initial header `fmt.Fprintf` to the freshly-opened log fails, since that failure was previously ignored entirely and would otherwise leave the tee wrongly believing the sink is healthy. The `pterm` import was added; it is already a transitive dependency of this codebase (used elsewhere in the CLI for terminal output) so no new module needs to be written. The underlying "never closes file if a panic occurs before defer runs" half of the finding is not addressed β€” `os.OpenFile`'s handle in `opSinks` is only closed via the deferred `close()` in the callers (Apply/Destroy/ApplyPlan), and a panic between open and defer-registration is a pre-existing, extremely narrow window not touched by this change; a complete fix would require restructuring `opSinks` to register the close before returning to callers, which is a larger structural change than this finding's stated primary concern (silent error suppression) warrants.
Verify the change is correct and complete; do not refactor unrelated code.

fix confidence: 🟑 70 medium β€” react πŸ‘/πŸ‘Ž to teach the reviewer

if t.sink != nil {
if _, err := t.sink.Write(p); err != nil {
t.sink = nil
if !t.warned {
t.warned = true
pterm.Warning.Printfln("operation log %s stopped recording (write error: %v); the on-disk log will be incomplete", t.logPath, err)
}
}
}
return t.progress.Write(p)
Expand All @@ -164,7 +174,8 @@ func (t *bestEffortTee) Write(p []byte) (int, error) {
// writer, teed into dir's terraform.log when it can be opened. Logging is
// best-effort end to end β€” a directory that cannot take the log (read-only,
// gone) skips it, and a write failure after opening only stops the mirroring
// (bestEffortTee above); neither may ever fail the operation itself.
// (bestEffortTee above, which also warns once); neither may ever fail the
// operation itself.
// The returned close is always safe to call; logPath is empty when no log is
// being written.
func (e *Engine) opSinks(dir, op string) (w io.Writer, close func(), logPath string) {
Expand All @@ -173,8 +184,12 @@ func (e *Engine) opSinks(dir, op string) (w io.Writer, close func(), logPath str
if err != nil {
return progress, func() {}, ""
}
fmt.Fprintf(f, "=== terraform %s β€” %s ===\n", op, time.Now().UTC().Format(time.RFC3339))
return &bestEffortTee{progress: progress, sink: f}, func() { _ = f.Close() }, f.Name()
if _, err := fmt.Fprintf(f, "=== terraform %s β€” %s ===\n", op, time.Now().UTC().Format(time.RFC3339)); err != nil {
pterm.Warning.Printfln("operation log %s could not be written to (%v); the on-disk log will be incomplete", f.Name(), err)
_ = f.Close()
return progress, func() {}, ""
}
return &bestEffortTee{progress: progress, sink: f, logPath: f.Name()}, func() { _ = f.Close() }, f.Name()
}

// opFailure wraps a failed apply/destroy, pointing at the full log when one
Expand Down
Loading
Loading