Skip to content
Open
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
24 changes: 24 additions & 0 deletions cmd/admin_cluster_operator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package cmd

import (
"os"

"github.com/qovery/qovery-cli/utils"
"github.com/spf13/cobra"
)

var adminClusterOperatorCmd = &cobra.Command{
Use: "operator",
Short: "Manage the Qovery Operator fleet",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
if len(args) == 0 {
_ = cmd.Help()
os.Exit(0)
}
},
}

func init() {
adminClusterCmd.AddCommand(adminClusterOperatorCmd)
}
159 changes: 159 additions & 0 deletions cmd/admin_cluster_operator_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"

"github.com/qovery/qovery-cli/utils"
qovery "github.com/qovery/qovery-client-go"
"github.com/spf13/cobra"
)

var adminClusterOperatorJSON bool

var adminClusterOperatorListCmd = &cobra.Command{
Use: "list",
Short: "List the Qovery Operator fleet",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)

tokenType, token, err := utils.GetAccessToken()
if err != nil {
utils.PrintlnError(err)
return
}
fleet, err := getClusterOperatorFleet(
context.Background(),
utils.GetAdminUrl(),
utils.GetAuthorizationHeaderValue(tokenType, token),
&http.Client{Timeout: 60 * time.Second},
)
if err != nil {
utils.PrintlnError(err)
return
}

clusters := attachedClusterOperators(fleet.GetResults())
if adminClusterOperatorJSON {
output, err := json.MarshalIndent(clusters, "", " ")
if err != nil {
utils.PrintlnError(err)
return
}
utils.Println(string(output))
return
}

if err := utils.PrintTable(
[]string{
"Organization ID",
"Cluster ID",
"Cluster",
"Kind",
"Attached",
"Connected",
"Last heartbeat",
"Status",
"Image",
"Target image",
"Chart",
"Target chart",
},
clusterOperatorFleetRows(clusters),
); err != nil {
utils.PrintlnError(err)
os.Exit(1)
}
},
}

func attachedClusterOperators(clusters []qovery.ClusterOperatorFleetInventoryResponse) []qovery.ClusterOperatorFleetInventoryResponse {
attached := make([]qovery.ClusterOperatorFleetInventoryResponse, 0, len(clusters))
for _, cluster := range clusters {
if cluster.Attached {
attached = append(attached, cluster)
}
}
return attached
}

func getClusterOperatorFleet(
ctx context.Context,
adminURL string,
authorization string,
httpClient *http.Client,
) (*qovery.ClusterOperatorFleetInventoryResponseList, error) {
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
strings.TrimRight(adminURL, "/")+"/operator/clusters",
nil,
)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", authorization)
request.Header.Set("Accept", "application/json")

response, err := httpClient.Do(request)
if err != nil {
return nil, err
}
defer func() { _ = response.Body.Close() }()

if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
return nil, fmt.Errorf("operator fleet API returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}

var fleet qovery.ClusterOperatorFleetInventoryResponseList
if err := json.NewDecoder(response.Body).Decode(&fleet); err != nil {
return nil, err
}
return &fleet, nil
}

func clusterOperatorFleetRows(clusters []qovery.ClusterOperatorFleetInventoryResponse) [][]string {
sort.Slice(clusters, func(left int, right int) bool {
if clusters[left].OrganizationId == clusters[right].OrganizationId {
return clusters[left].ClusterName < clusters[right].ClusterName
}
return clusters[left].OrganizationId < clusters[right].OrganizationId
})

rows := make([][]string, 0, len(clusters))
for _, cluster := range clusters {
lastHeartbeat := "never"
if heartbeat := cluster.LastHeartbeat.Get(); heartbeat != nil {
lastHeartbeat = heartbeat.Format(time.RFC3339)
}
rows = append(rows, []string{
cluster.OrganizationId,
cluster.ClusterId,
cluster.ClusterName,
string(cluster.ClusterKind),
strconv.FormatBool(cluster.Attached),
strconv.FormatBool(cluster.Connected),
lastHeartbeat,
string(cluster.Status),
displayVersion(cluster.ReportedImageVersion),
displayVersion(cluster.DesiredImageVersion),
displayVersion(cluster.ReportedChartVersion),
displayVersion(cluster.DesiredChartVersion),
})
}
return rows
}

func init() {
adminClusterOperatorCmd.AddCommand(adminClusterOperatorListCmd)
adminClusterOperatorListCmd.Flags().BoolVar(&adminClusterOperatorJSON, "json", false, "JSON output")
}
100 changes: 100 additions & 0 deletions cmd/admin_cluster_operator_list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package cmd

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

qovery "github.com/qovery/qovery-client-go"
)

func TestGetClusterOperatorFleetUsesAdminRoute(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/operator/clusters" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: t.Fatalf is called from inside the httptest handler goroutine. The Go testing docs state FailNow (and thus Fatalf) must be called from the goroutine running the test, not from other goroutines; here it can race with the test goroutine and doesn't stop the other goroutines as expected. Return an error from the handler (e.g. write a non-200 status) and assert it on the test goroutine instead, or use t.Errorf in the handler followed by a signal the main goroutine can wait on.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/admin_cluster_operator_list_test.go, line 16:

<comment>`t.Fatalf` is called from inside the `httptest` handler goroutine. The Go testing docs state FailNow (and thus Fatalf) must be called from the goroutine running the test, not from other goroutines; here it can race with the test goroutine and doesn't stop the other goroutines as expected. Return an error from the handler (e.g. write a non-200 status) and assert it on the test goroutine instead, or use `t.Errorf` in the handler followed by a signal the main goroutine can wait on.</comment>

<file context>
@@ -0,0 +1,100 @@
+
+func TestGetClusterOperatorFleetUsesAdminRoute(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+		if request.URL.Path != "/operator/clusters" {
+			t.Fatalf("unexpected path %s", request.URL.Path)
+		}
</file context>

t.Fatalf("unexpected path %s", request.URL.Path)
}
if request.Header.Get("Authorization") != "Bearer token" {
t.Fatal("missing authorization header")
}
writer.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(writer, `{"results":[{"organization_id":"org-1","cluster_id":"cluster-1","cluster_name":"customer-cluster","cluster_kind":"SELF_MANAGED","attached":true,"connected":true,"status":"CURRENT"}]}`)
}))
defer server.Close()

fleet, err := getClusterOperatorFleet(context.Background(), server.URL, "Bearer token", server.Client())
if err != nil {
t.Fatal(err)
}
if len(fleet.Results) != 1 || fleet.Results[0].ClusterName != "customer-cluster" {
t.Fatalf("unexpected fleet: %#v", fleet.Results)
}
}

func TestClusterOperatorFleetRows(t *testing.T) {
heartbeat := time.Date(2026, time.August, 18, 12, 28, 46, 0, time.UTC)
current := qovery.NewClusterOperatorFleetInventoryResponse(
"org-1",
"cluster-1",
"customer-cluster",
qovery.SELFMANAGEDCLUSTERKIND_SELF_MANAGED,
true,
true,
qovery.CLUSTEROPERATORFLEETSTATUS_CURRENT,
)
current.SetLastHeartbeat(heartbeat)
current.SetReportedImageVersion("v1.203.0")
current.SetDesiredImageVersion("v1.203.0")
current.SetReportedChartVersion("0.2.1")
current.SetDesiredChartVersion("0.2.1")
disconnected := qovery.NewClusterOperatorFleetInventoryResponse(
"org-1",
"cluster-2",
"another-cluster",
qovery.SELFMANAGEDCLUSTERKIND_EKS_SELF_MANAGED,
true,
false,
qovery.CLUSTEROPERATORFLEETSTATUS_DISCONNECTED,
)

rows := clusterOperatorFleetRows([]qovery.ClusterOperatorFleetInventoryResponse{*current, *disconnected})

if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0][2] != "another-cluster" || rows[0][6] != "never" || rows[0][7] != "DISCONNECTED" {
t.Fatalf("unexpected disconnected row: %#v", rows[0])
}
if rows[1][6] != "2026-08-18T12:28:46Z" || rows[1][8] != "v1.203.0" || rows[1][10] != "0.2.1" {
t.Fatalf("unexpected current row: %#v", rows[1])
}
}

func TestAttachedClusterOperators(t *testing.T) {
attached := qovery.NewClusterOperatorFleetInventoryResponse(
"org-1",
"cluster-1",
"attached-cluster",
qovery.SELFMANAGEDCLUSTERKIND_SELF_MANAGED,
true,
true,
qovery.CLUSTEROPERATORFLEETSTATUS_CURRENT,
)
notAttached := qovery.NewClusterOperatorFleetInventoryResponse(
"org-1",
"cluster-2",
"local-cluster",
qovery.SELFMANAGEDCLUSTERKIND_SELF_MANAGED,
false,
false,
qovery.CLUSTEROPERATORFLEETSTATUS_NOT_ATTACHED,
)

clusters := attachedClusterOperators([]qovery.ClusterOperatorFleetInventoryResponse{*notAttached, *attached})

if len(clusters) != 1 || clusters[0].ClusterId != "cluster-1" {
t.Fatalf("unexpected attached clusters: %#v", clusters)
}
}
24 changes: 24 additions & 0 deletions cmd/cluster_operator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package cmd

import (
"os"

"github.com/qovery/qovery-cli/utils"
"github.com/spf13/cobra"
)

var clusterOperatorCmd = &cobra.Command{
Use: "operator",
Short: "Manage the Qovery Operator on a cluster",
Run: func(cmd *cobra.Command, args []string) {
utils.Capture(cmd)
if len(args) == 0 {
_ = cmd.Help()
os.Exit(0)
}
},
}

func init() {
clusterCmd.AddCommand(clusterOperatorCmd)
}
61 changes: 61 additions & 0 deletions cmd/cluster_operator_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package cmd

import (
"context"
"fmt"

"github.com/qovery/qovery-cli/pkg/usercontext"
"github.com/qovery/qovery-cli/utils"
qovery "github.com/qovery/qovery-client-go"
)

type operatorCommandContext struct {
api *qovery.APIClient
clusterID string
organizationID string
}

func newOperatorCommandContext(organizationName string, clusterName string) (*operatorCommandContext, error) {
tokenType, token, err := utils.GetAccessToken()
if err != nil {
return nil, err
}

client := utils.GetQoveryClient(tokenType, token)
organizationID, err := usercontext.GetOrganizationContextResourceId(client, organizationName)
if err != nil {
return nil, err
}

clusters, _, err := client.ClustersAPI.ListOrganizationCluster(context.Background(), organizationID).Execute()
if err != nil {
return nil, err
}
cluster := findCluster(clusters.GetResults(), clusterName)
if cluster == nil {
return nil, fmt.Errorf("cluster %s not found", clusterName)
}

return &operatorCommandContext{
api: client,
clusterID: cluster.Id,
organizationID: organizationID,
}, nil
}

func findCluster(clusters []qovery.Cluster, name string) *qovery.Cluster {
for index := range clusters {
if clusters[index].Name == name {
return &clusters[index]
}
}
return nil
}
Comment on lines +46 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When --cluster contains leading or trailing whitespace, findCluster reports the cluster as missing even though other cluster commands resolve the same input. Delegate to utils.FindByClusterName so operator status uses the existing whitespace-tolerant lookup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/cluster_operator_helpers.go, line 46:

<comment>When `--cluster` contains leading or trailing whitespace, `findCluster` reports the cluster as missing even though other cluster commands resolve the same input. Delegate to `utils.FindByClusterName` so operator status uses the existing whitespace-tolerant lookup.</comment>

<file context>
@@ -0,0 +1,61 @@
+	}, nil
+}
+
+func findCluster(clusters []qovery.Cluster, name string) *qovery.Cluster {
+	for index := range clusters {
+		if clusters[index].Name == name {
</file context>
Suggested change
func findCluster(clusters []qovery.Cluster, name string) *qovery.Cluster {
for index := range clusters {
if clusters[index].Name == name {
return &clusters[index]
}
}
return nil
}
func findCluster(clusters []qovery.Cluster, name string) *qovery.Cluster {
return utils.FindByClusterName(clusters, name)
}


func displayVersion(version qovery.NullableString) string {
value := version.Get()
if value == nil || *value == "" {
return "unknown"
}
return *value
}
Loading
Loading