diff --git a/cmd/nomos/status/client.go b/cmd/nomos/status/client.go index 290ed3727f..ec0e9bc2ac 100644 --- a/cmd/nomos/status/client.go +++ b/cmd/nomos/status/client.go @@ -168,15 +168,18 @@ func (c *ClusterClient) clusterStatus(ctx context.Context, cluster, namespace st if namespace == configsync.ControllerNamespace { if err := c.rootRepoClusterStatus(ctx, cs); err != nil { + cs.noSyncObjects = errors.Is(err, ErrNoRootSyncsFound) cs.Error = err.Error() } } else if namespace != "" { if err := c.namespaceRepoClusterStatus(ctx, cs, namespace); err != nil { + cs.noSyncObjects = errors.Is(err, ErrNoRepoSyncsFound) cs.Error = err.Error() } } else if isOss || (cs.isMulti != nil && *cs.isMulti) { if err := c.multiRepoClusterStatus(ctx, cs); err != nil { + cs.noSyncObjects = errors.Is(err, ErrNoRootSyncsFound) && errors.Is(err, ErrNoRepoSyncsFound) cs.Error = err.Error() } } else { diff --git a/cmd/nomos/status/cluster_state.go b/cmd/nomos/status/cluster_state.go index 02880ca9ff..320aa1366b 100644 --- a/cmd/nomos/status/cluster_state.go +++ b/cmd/nomos/status/cluster_state.go @@ -41,9 +41,48 @@ type ClusterState struct { Ref string status string // Error represents the sync errors - Error string - repos []*RepoState - isMulti *bool + Error string + noSyncObjects bool + repos []*RepoState + isMulti *bool +} + +// pollUntilReached checks the selected repositories against the requested sync +// or resource-readiness target. Empty clusters do not block polling, but a +// requested name must appear before it can be considered complete. +func pollUntilReached(states map[string]*ClusterState, syncName, target string) (bool, error) { + matchedRepo := false + for _, state := range states { + if state == nil || (state.Error != "" && !state.noSyncObjects) { + return false, nil + } + if state.noSyncObjects { + continue + } + for _, repo := range state.repos { + if repo == nil { + return false, nil + } + if syncName != "" && repo.syncName != syncName { + continue + } + matchedRepo = true + if repo.status != syncedMsg || len(repo.errors) > 0 { + return false, nil + } + if target != pollUntilSynced { + for _, resource := range repo.resources { + if resource.Status == kptv1alpha1.Failed { + return false, fmt.Errorf("resource %s managed by %s:%s has status %q", resourceStatusToString(resource), repo.scope, repo.syncName, resource.Status) + } + if resource.Status != kptv1alpha1.Current { + return false, nil + } + } + } + } + } + return syncName == "" || matchedRepo, nil } func (c *ClusterState) printRows(writer io.Writer) { diff --git a/cmd/nomos/status/poll_until_test.go b/cmd/nomos/status/poll_until_test.go new file mode 100644 index 0000000000..d14f5e84db --- /dev/null +++ b/cmd/nomos/status/poll_until_test.go @@ -0,0 +1,228 @@ +// 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 +// +// http://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 status + +import ( + "strings" + "testing" + + kptv1alpha1 "github.com/GoogleContainerTools/config-sync/pkg/api/kpt.dev/v1alpha1" +) + +func TestPollUntilReached(t *testing.T) { + tests := []struct { + name string + states map[string]*ClusterState + want bool + wantErr bool + }{ + { + name: "all repositories synced", + states: map[string]*ClusterState{ + "cluster": { + repos: []*RepoState{ + {status: syncedMsg}, + }, + }, + }, + want: true, + }, + { + name: "pending repository", + states: map[string]*ClusterState{ + "cluster": { + repos: []*RepoState{ + {status: pendingMsg}, + }, + }, + }, + }, + { + name: "repository with non-current resource", + states: map[string]*ClusterState{ + "cluster": { + repos: []*RepoState{ + { + status: syncedMsg, + resources: []kptv1alpha1.ResourceStatus{ + {Status: kptv1alpha1.Failed}, + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "cluster error", + states: map[string]*ClusterState{ + "cluster": { + Error: "unavailable", + repos: []*RepoState{ + {status: syncedMsg}, + }, + }, + }, + }, + { + name: "empty reachable cluster has nothing to wait for", + states: map[string]*ClusterState{ + "cluster": {}, + }, + want: true, + }, + { + name: "cluster without sync objects does not block another cluster", + states: map[string]*ClusterState{ + "empty-cluster": {Error: "No RootSync resources found\nNo RepoSync resources found", noSyncObjects: true}, + "active-cluster": {repos: []*RepoState{{status: syncedMsg}}}, + }, + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := pollUntilReached(test.states, "", pollUntilCurrent) + if (err != nil) != test.wantErr { + t.Fatalf("pollUntilReached() error = %v, want error %v", err, test.wantErr) + } + if got != test.want { + t.Fatalf("pollUntilReached() = %v, want %v", got, test.want) + } + }) + } +} + +func TestPollUntilReachedRequiresCurrentResourceForCurrentMode(t *testing.T) { + tests := []struct { + name string + resourceState kptv1alpha1.Status + want bool + wantErr bool + }{ + {name: "current resource", resourceState: kptv1alpha1.Current, want: true}, + {name: "unknown resource", resourceState: kptv1alpha1.Unknown, want: false}, + {name: "failed resource", resourceState: kptv1alpha1.Failed, want: false, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + states := map[string]*ClusterState{ + "cluster": {repos: []*RepoState{{ + status: syncedMsg, + resources: []kptv1alpha1.ResourceStatus{{Status: test.resourceState}}, + }}}, + } + got, err := pollUntilReached(states, "", pollUntilCurrent) + if (err != nil) != test.wantErr { + t.Fatalf("pollUntilReached() error = %v, want error %v", err, test.wantErr) + } + if got != test.want { + t.Fatalf("pollUntilReached() = %v, want %v", got, test.want) + } + }) + } +} + +func TestPollUntilReachedFailsForFailedResourceInCurrentMode(t *testing.T) { + states := map[string]*ClusterState{ + "cluster": {repos: []*RepoState{{ + scope: "apps", + syncName: "payments", + status: syncedMsg, + resources: []kptv1alpha1.ResourceStatus{{ + ObjMetadata: kptv1alpha1.ObjMetadata{ + GroupKind: kptv1alpha1.GroupKind{Group: "apps", Kind: "Deployment"}, + Name: "checkout", + }, + Status: kptv1alpha1.Failed, + }}, + }}}, + } + + done, err := pollUntilReached(states, "", pollUntilCurrent) + if err == nil { + t.Fatal("pollUntilReached() returned no error for a failed resource") + } + if done { + t.Fatal("pollUntilReached() reported completion for a failed resource") + } + if !strings.Contains(err.Error(), "deployment.apps/checkout") { + t.Fatalf("pollUntilReached() error = %q, want the failed resource identified", err) + } +} + +func TestPollUntilReachedUsesNameFilter(t *testing.T) { + states := map[string]*ClusterState{ + "cluster": { + repos: []*RepoState{ + {syncName: "selected", status: syncedMsg}, + {syncName: "other", status: pendingMsg}, + }, + }, + } + got, err := pollUntilReached(states, "selected", pollUntilCurrent) + if err != nil { + t.Fatalf("pollUntilReached() returned unexpected error: %v", err) + } + if !got { + t.Fatal("pollUntilReached() = false, want true when the selected repo is synced") + } +} + +func TestPollUntilReachedWaitsForNamedRepoToAppear(t *testing.T) { + states := map[string]*ClusterState{"cluster": {repos: []*RepoState{{syncName: "other", status: syncedMsg}}}} + if got, err := pollUntilReached(states, "selected", pollUntilCurrent); err != nil || got { + t.Fatalf("pollUntilReached() = (%v, %v), want (false, nil) when no repo matches", got, err) + } +} + +func TestPollUntilReachedSyncedTargetIgnoresResourceReadiness(t *testing.T) { + states := map[string]*ClusterState{ + "cluster": { + repos: []*RepoState{{ + syncName: "selected", + status: syncedMsg, + resources: []kptv1alpha1.ResourceStatus{ + {Status: kptv1alpha1.Unknown}, + }, + }}, + }, + } + got, err := pollUntilReached(states, "", pollUntilSynced) + if err != nil { + t.Fatalf("pollUntilReached() returned unexpected error: %v", err) + } + if !got { + t.Fatal("pollUntilReached() = false, want true when sync completed and mode is synced") + } +} + +func TestValidatePollUntil(t *testing.T) { + if err := validatePollUntil(""); err != nil { + t.Fatalf("validatePollUntil(\"\") returned error: %v", err) + } + if err := validatePollUntil(pollUntilComplete); err != nil { + t.Fatalf("validatePollUntil(%q) returned error: %v", pollUntilComplete, err) + } + for _, value := range []string{pollUntilSynced, pollUntilCurrent} { + if err := validatePollUntil(value); err != nil { + t.Errorf("validatePollUntil(%q) returned error: %v", value, err) + } + } + if err := validatePollUntil("ready"); err == nil { + t.Fatal("validatePollUntil(\"ready\") returned nil") + } +} diff --git a/cmd/nomos/status/status.go b/cmd/nomos/status/status.go index c56db29a70..53b06cd2fe 100644 --- a/cmd/nomos/status/status.go +++ b/cmd/nomos/status/status.go @@ -34,14 +34,19 @@ import ( ) const ( - pendingMsg = "PENDING" - syncedMsg = "SYNCED" - stalledMsg = "STALLED" - reconcilingMsg = "RECONCILING" + pendingMsg = "PENDING" + syncedMsg = "SYNCED" + stalledMsg = "STALLED" + reconcilingMsg = "RECONCILING" + pollUntilSynced = "synced" + pollUntilCurrent = "current" + pollUntilComplete = "complete" + defaultPollInterval = 5 * time.Second ) var ( pollingInterval time.Duration + pollUntil string namespace string resourceStatus bool name string @@ -51,6 +56,7 @@ func init() { flags.AddContexts(Cmd) Cmd.Flags().DurationVar(&flags.ClientTimeout, "timeout", restconfig.DefaultTimeout, "Sets the timeout for connecting to each cluster. Defaults to 15 seconds. Example: --timeout=30s") Cmd.Flags().DurationVar(&pollingInterval, "poll", 0*time.Second, "Continuously polls for status updates at the specified interval. If not provided, the command runs only once. Example: --poll=30s for polling every 30 seconds") + Cmd.Flags().StringVar(&pollUntil, "poll-until", "", "Continues polling until the requested state is reached. Supported values: synced, current (complete is an alias for current). Defaults to a 5-second polling interval when --poll is not set.") Cmd.Flags().StringVar(&namespace, "namespace", "", "Filters the status output by the specified RootSync or RepoSync namespace. If not provided, displays status for all RootSync and RepoSync objects.") Cmd.Flags().BoolVar(&resourceStatus, "resources", true, "Displays detailed status for individual resources managed by RootSync or RepoSync objects. Defaults to true.") Cmd.Flags().StringVar(&name, "name", "", "Filters the status output by the specified RootSync or RepoSync name.") @@ -74,7 +80,8 @@ func SaveToTempFile(ctx context.Context, contexts []string) (*os.File, error) { } names := clusterNames(clientMap) - printStatus(ctx, writer, clientMap, names) + stateMap, monoRepoClusters := clusterStates(ctx, clientMap) + printStatus(writer, stateMap, monoRepoClusters, names) err = tmpFile.Close() if err != nil { return tmpFile, fmt.Errorf("failed to close status file writer with error: %w", err) @@ -97,6 +104,9 @@ var Cmd = &cobra.Command{ RunE: func(cmd *cobra.Command, _ []string) error { // Don't show usage on error, as argument validation passed. cmd.SilenceUsage = true + if err := validatePollUntil(pollUntil); err != nil { + return err + } fmt.Println("Connecting to clusters...") @@ -116,13 +126,27 @@ var Cmd = &cobra.Command{ names := clusterNames(clientMap) writer := util.NewWriter(os.Stdout) + if pollUntil != "" && pollingInterval == 0 { + pollingInterval = defaultPollInterval + } if pollingInterval > 0 { for { - printStatus(cmd.Context(), writer, clientMap, names) + stateMap, monoRepoClusters := clusterStates(cmd.Context(), clientMap) + printStatus(writer, stateMap, monoRepoClusters, names) + if pollUntil != "" { + done, completionErr := pollUntilReached(stateMap, name, pollUntil) + if completionErr != nil { + return completionErr + } + if done { + return nil + } + } time.Sleep(pollingInterval) } } else { - printStatus(cmd.Context(), writer, clientMap, names) + stateMap, monoRepoClusters := clusterStates(cmd.Context(), clientMap) + printStatus(writer, stateMap, monoRepoClusters, names) } return nil }, @@ -157,14 +181,11 @@ func clusterStates(ctx context.Context, clientMap map[string]*ClusterClient) (ma return stateMap, monoRepoClusters } -// printStatus fetches ConfigManagementStatus and/or RepoStatus from each cluster in the given map -// and then prints a formatted status row for each one. If there are any errors reported by either -// object, those are printed in a second table under the status table. +// printStatus writes the collected cluster states in a formatted table. If +// there are any errors reported by either object, those are printed in a +// second table under the status table. // nolint:errcheck -func printStatus(ctx context.Context, writer *tabwriter.Writer, clientMap map[string]*ClusterClient, names []string) { - // First build up a map of all the states to display. - stateMap, monoRepoClusters := clusterStates(ctx, clientMap) - +func printStatus(writer *tabwriter.Writer, stateMap map[string]*ClusterState, monoRepoClusters, names []string) { // Log a notice for the detected clusters that are running in the mono-repo mode. util.MonoRepoNotice(writer, monoRepoClusters...) @@ -194,6 +215,13 @@ func printStatus(ctx context.Context, writer *tabwriter.Writer, clientMap map[st writer.Flush() } +func validatePollUntil(value string) error { + if value != "" && value != pollUntilSynced && value != pollUntilCurrent && value != pollUntilComplete { + return fmt.Errorf("unsupported --poll-until value %q; supported values are %q, %q, and %q", value, pollUntilSynced, pollUntilCurrent, pollUntilComplete) + } + return nil +} + // clearTerminal executes an OS-specific command to clear all output on the terminal. func clearTerminal(out io.Writer) { var cmd *exec.Cmd diff --git a/cmd/nomos/status/status_test.go b/cmd/nomos/status/status_test.go index f1f32d0f92..f9b7ecd0b1 100644 --- a/cmd/nomos/status/status_test.go +++ b/cmd/nomos/status/status_test.go @@ -361,8 +361,9 @@ func TestClusterStates(t *testing.T) { }, wantStateMap: map[string]*ClusterState{ "multi-repo-cluster": { - Ref: "multi-repo-cluster", - Error: "No RootSync resources found; No RepoSync resources found", + Ref: "multi-repo-cluster", + Error: "No RootSync resources found; No RepoSync resources found", + noSyncObjects: true, }, }, wantMonoRepoClusters: nil, @@ -572,7 +573,8 @@ func TestPrintStatus(t *testing.T) { var buf bytes.Buffer writer := tabwriter.NewWriter(&buf, 0, 0, 5, ' ', 0) - printStatus(context.Background(), writer, tc.clientMap, tc.names) + stateMap, monoRepoClusters := clusterStates(context.Background(), tc.clientMap) + printStatus(writer, stateMap, monoRepoClusters, tc.names) got := buf.String() if diff := cmp.Diff(tc.want, got); diff != "" { diff --git a/docs/design-docs/03-poll-until-status.md b/docs/design-docs/03-poll-until-status.md new file mode 100644 index 0000000000..5c94160865 --- /dev/null +++ b/docs/design-docs/03-poll-until-status.md @@ -0,0 +1,103 @@ +# Poll until status is complete + +* Author(s): ahmadalguydi +* Approver: +* Status: provisional + +## Summary + +Add `--poll-until` targets to `nomos status` so automation can wait either for +RootSync and RepoSync objects to finish syncing or for their reported managed +resources to become current. The command prints the initial status immediately, +then refreshes it until the selected target is reached or the caller's external +timeout terminates the process. + +## Motivation + +Issue [#2006](https://github.com/GoogleContainerTools/config-sync/issues/2006) +requests a bounded way to wait for eventual reconciliation without requiring +scripts to parse repeated status output. + +## Design Overview + +The existing `--poll` loop is extended with optional completion targets: +`--poll-until=synced` waits until each selected repository reports `SYNCED` +without errors; `--poll-until=current` adds the requirement that every reported +managed resource is `Current`. `complete` remains an alias for `current`. +A stalled, pending, reconciling, unavailable, or errored repository keeps +polling. A `Failed` managed resource stops `current` polling with an error +instead of waiting indefinitely. An `Unknown` resource does not count as +current; callers that only need successful Config Sync application can use +`synced` instead. Invalid values fail before any cluster request. + +The `--name` filter also scopes the completion check. A requested name must be +present in at least one queried cluster before polling can succeed. Reachable +clusters with no sync objects do not block other clusters from completing. + +Either completion target uses a five-second interval when `--poll` is omitted. +An explicit `--poll` interval continues to take precedence. Existing commands +without `--poll-until` retain their current one-shot or indefinite polling +behavior. + +The `current` target checks managed-resource status independently of the +`--resources` display flag. A repository with no managed resources is complete +once its sync status is `SYNCED`. `Unknown`, `InProgress`, and other non-current +states keep polling, while `Failed` returns an error. The `synced` target does +not wait for managed-resource status. + +## User Guide + +Run a status check once as before: + +```shell +nomos status +``` + +Wait until all repositories have finished syncing, refreshing every 10 seconds: + +```shell +timeout 2m nomos status --poll=10s --poll-until=synced +``` + +Wait for all reported managed resources to reach `Current`: + +```shell +timeout 2m nomos status --poll=10s --poll-until=current +``` + +When the requested state is reached, the command exits successfully after +printing that status. `current` exits with an error if any resource reports +`Failed`. If the external timeout expires first, the process is terminated and +the caller can treat that as a reconciliation timeout. + +## Risks and Mitigations + +| Risk | Mitigation | +| --- | --- | +| Polling can increase API traffic. | Require an explicit completion mode and use a conservative five-second default; callers can choose a longer interval. | +| A cluster with no sync objects could hold multi-cluster polling open. | Ignore empty clusters while checking other clusters; require a requested `--name` to appear before success. | +| A resource may never report kstatus `Current`. | Offer `synced` for apply completion and `current` for resource readiness; fail fast for `Failed` resources. | +| Existing users depend on indefinite `--poll`. | Preserve the existing loop unless `--poll-until` is provided. | + +## Test Plan + +Unit tests cover `synced` and `current` targets, name filtering, empty clusters, +current, unknown, and failed resource states, plus validation of supported flag +values. Existing status tests continue to exercise rendering and cluster-state +collection. + +## Open Issues/Questions + +### Should more completion states be supported? + +Resolution: Yes. `synced` covers Config Sync application, and `current` covers +managed-resource readiness. `complete` is retained as a compatibility alias for +`current`. + +## Alternatives Considered + +### Shell-side output parsing + +Parsing repeated `nomos status` output is fragile and requires callers to know +the output format. A structured completion predicate keeps the behavior inside +the CLI while preserving its human-readable output.