diff --git a/cmd/admin_cluster_operator.go b/cmd/admin_cluster_operator.go new file mode 100644 index 00000000..50141aa2 --- /dev/null +++ b/cmd/admin_cluster_operator.go @@ -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) +} diff --git a/cmd/admin_cluster_operator_list.go b/cmd/admin_cluster_operator_list.go new file mode 100644 index 00000000..3d4f287d --- /dev/null +++ b/cmd/admin_cluster_operator_list.go @@ -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") +} diff --git a/cmd/admin_cluster_operator_list_test.go b/cmd/admin_cluster_operator_list_test.go new file mode 100644 index 00000000..731fbc98 --- /dev/null +++ b/cmd/admin_cluster_operator_list_test.go @@ -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" { + 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) + } +} diff --git a/cmd/cluster_operator.go b/cmd/cluster_operator.go new file mode 100644 index 00000000..521dad02 --- /dev/null +++ b/cmd/cluster_operator.go @@ -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) +} diff --git a/cmd/cluster_operator_helpers.go b/cmd/cluster_operator_helpers.go new file mode 100644 index 00000000..610a9919 --- /dev/null +++ b/cmd/cluster_operator_helpers.go @@ -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 +} + +func displayVersion(version qovery.NullableString) string { + value := version.Get() + if value == nil || *value == "" { + return "unknown" + } + return *value +} diff --git a/cmd/cluster_operator_status.go b/cmd/cluster_operator_status.go new file mode 100644 index 00000000..c8f75a6d --- /dev/null +++ b/cmd/cluster_operator_status.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "context" + "encoding/json" + "os" + + "github.com/qovery/qovery-cli/utils" + "github.com/spf13/cobra" +) + +var operatorStatusOrganization string +var operatorStatusCluster string +var operatorStatusJSON bool + +var clusterOperatorStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show the Qovery Operator connection and version status", + Run: func(cmd *cobra.Command, args []string) { + utils.Capture(cmd) + + commandContext, err := newOperatorCommandContext(operatorStatusOrganization, operatorStatusCluster) + if err != nil { + utils.PrintlnError(err) + return + } + status, _, err := commandContext.api.ClusterOperatorAPI. + GetClusterOperatorStatus(context.Background(), commandContext.organizationID, commandContext.clusterID). + Execute() + if err != nil { + utils.PrintlnError(err) + return + } + + if operatorStatusJSON { + output, err := json.MarshalIndent(status, "", " ") + if err != nil { + utils.PrintlnError(err) + return + } + utils.Println(string(output)) + return + } + + connected := "no" + if status.OperatorConnected { + connected = "yes" + } + lastHeartbeat := "never" + if heartbeat := status.LastHeartbeat.Get(); heartbeat != nil { + lastHeartbeat = heartbeat.Format("2006-01-02T15:04:05Z07:00") + } + data := [][]string{{ + string(status.Status), + connected, + lastHeartbeat, + displayVersion(status.OperatorVersion), + displayVersion(status.DesiredImageVersion), + displayVersion(status.ReportedChartVersion), + displayVersion(status.DesiredChartVersion), + }} + if err := utils.PrintTable( + []string{"Status", "Connected", "Last heartbeat", "Image", "Target image", "Chart", "Target chart"}, + data, + ); err != nil { + utils.PrintlnError(err) + os.Exit(1) + } + }, +} + +func init() { + clusterOperatorCmd.AddCommand(clusterOperatorStatusCmd) + clusterOperatorStatusCmd.Flags().StringVar(&operatorStatusOrganization, "organization", "", "Organization name") + clusterOperatorStatusCmd.Flags().StringVar(&operatorStatusCluster, "cluster", "", "Cluster name") + clusterOperatorStatusCmd.Flags().BoolVar(&operatorStatusJSON, "json", false, "JSON output") + _ = clusterOperatorStatusCmd.MarkFlagRequired("cluster") +} diff --git a/cmd/demo2.go b/cmd/demo2.go new file mode 100644 index 00000000..ae4a4943 --- /dev/null +++ b/cmd/demo2.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "github.com/qovery/qovery-cli/utils" + "github.com/spf13/cobra" +) + +var demo2Cmd = &cobra.Command{ + Use: "demo2", + Short: "Try the experimental local demo based on Qovery Operator and Engine V2", + Long: `Try the experimental local demo based on Qovery Operator and Engine V2. + +This proof validates only: + CLI -> Operator bootstrap -> heartbeat -> cluster deployment + -> q-core compiles the current catalog -> RUN_ONCE worker installs the platform + +It does not yet validate complete legacy demo catalog parity, ingress-nginx, application builds or +deployments, or the complete heartbeat compatibility contract that must later be enforced by +q-core.`, + Run: func(cmd *cobra.Command, args []string) { + utils.Capture(cmd) + if len(args) == 0 { + _ = cmd.Help() + return + } + }, +} + +func init() { + rootCmd.AddCommand(demo2Cmd) +} diff --git a/cmd/demo2_destroy.go b/cmd/demo2_destroy.go new file mode 100644 index 00000000..e6d162eb --- /dev/null +++ b/cmd/demo2_destroy.go @@ -0,0 +1,272 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "runtime" + "strings" + + "github.com/qovery/qovery-cli/utils" + qovery "github.com/qovery/qovery-client-go" + "github.com/spf13/cobra" +) + +var ( + demo2DestroyClusterName string + demo2DestroyDeleteQoveryConfig bool +) + +var demo2DestroyCmd = &cobra.Command{ + Use: "destroy", + Short: "Remove the experimental local Operator demo", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + utils.Capture(cmd) + if runtime.GOOS == "windows" { + return errors.New("qovery demo2 is not supported directly on Windows; use WSL") + } + if err := validateDemo2ClusterName(demo2DestroyClusterName); err != nil { + return err + } + + var api demo2DestroyAPI + organizationID := "" + if demo2DestroyDeleteQoveryConfig { + tokenType, token, err := utils.GetAccessToken() + if err != nil { + return fmt.Errorf("authentication failed; run `qovery auth` first: %w", err) + } + organization, _, err := utils.CurrentOrganization(true) + if err != nil { + return fmt.Errorf("cannot resolve the current organization: %w", err) + } + organizationID = string(organization) + api = &demo2QoveryAPI{client: utils.GetQoveryClient(tokenType, token)} + } + + orchestrator := demo2DestroyOrchestrator{ + api: api, + local: &demo2LocalCommands{runner: &demo2ExecRunner{}, goos: runtime.GOOS}, + out: cmd.OutOrStdout(), + } + err := orchestrator.Destroy(cmd.Context(), demo2DestroyConfig{ + OrganizationID: organizationID, + ClusterName: demo2DestroyClusterName, + DeleteQoveryConfig: demo2DestroyDeleteQoveryConfig, + }) + if err == nil { + utils.CaptureWithEvent(cmd, utils.EndOfExecutionEventName) + } + return err + }, +} + +func init() { + demo2DestroyClusterName = "local-demo2-" + demo2SafeUsername() + demo2DestroyCmd.Flags().StringVarP(&demo2DestroyClusterName, "cluster-name", "c", demo2DestroyClusterName, "The name of the experimental local cluster") + demo2DestroyCmd.Flags().BoolVarP( + &demo2DestroyDeleteQoveryConfig, + "delete-qovery-config", + "d", + false, + "Also delete the Qovery cluster, its environments, and its Operator association", + ) + demo2Cmd.AddCommand(demo2DestroyCmd) +} + +type demo2DestroyAPI interface { + FindCluster(context.Context, string, string) (string, bool, error) + DeleteClusterConfig(context.Context, string, string) error +} + +type demo2DestroyLocal interface { + CheckDestroyDependencies(context.Context) error + DeleteK3dCluster(context.Context, string) error + TeardownLoopback(context.Context) error +} + +type demo2DestroyConfig struct { + OrganizationID string + ClusterName string + DeleteQoveryConfig bool +} + +type demo2DestroyOrchestrator struct { + api demo2DestroyAPI + local demo2DestroyLocal + out io.Writer +} + +func (o *demo2DestroyOrchestrator) Destroy(ctx context.Context, cfg demo2DestroyConfig) error { + o.phase("Checking local dependencies") + if err := o.local.CheckDestroyDependencies(ctx); err != nil { + return fmt.Errorf("local dependency check failed: %w", err) + } + + o.phase("Deleting the local k3d cluster and registry") + if err := o.local.DeleteK3dCluster(ctx, cfg.ClusterName); err != nil { + return fmt.Errorf("cannot delete local k3d resources: %w", err) + } + if err := o.local.TeardownLoopback(ctx); err != nil { + return fmt.Errorf("cannot remove local loopback configuration: %w", err) + } + + if !cfg.DeleteQoveryConfig { + _, _ = fmt.Fprintf(o.out, "Local Demo2 cluster %q was deleted. Its Qovery configuration and Operator association were preserved.\n", cfg.ClusterName) + return nil + } + if o.api == nil { + return errors.New("qovery API is required to delete the remote configuration") + } + + o.phase("Deleting the Qovery cluster configuration") + clusterID, found, err := o.api.FindCluster(ctx, cfg.OrganizationID, cfg.ClusterName) + if err != nil { + return fmt.Errorf("cannot look up Qovery cluster: %w", err) + } + if found { + if err := o.api.DeleteClusterConfig(ctx, cfg.OrganizationID, clusterID); err != nil { + return fmt.Errorf("cannot delete Qovery cluster configuration: %w", err) + } + } + _, _ = fmt.Fprintf(o.out, "Demo2 cluster %q and its Qovery configuration were deleted.\n", cfg.ClusterName) + return nil +} + +func (o *demo2DestroyOrchestrator) phase(message string) { + _, _ = fmt.Fprintf(o.out, "\n==> %s\n", message) +} + +func (a *demo2QoveryAPI) DeleteClusterConfig(ctx context.Context, organizationID string, clusterID string) error { + _, err := a.client.ClustersAPI.DeleteCluster(ctx, organizationID, clusterID). + DeleteMode(qovery.CLUSTERDELETEMODE_DELETE_QOVERY_CONFIG). + Execute() + return err +} + +func (l *demo2LocalCommands) CheckDestroyDependencies(_ context.Context) error { + for _, dependency := range []string{"docker", "k3d"} { + if err := l.runner.LookPath(dependency); err != nil { + return fmt.Errorf("required command %q is not installed", dependency) + } + } + return nil +} + +func (l *demo2LocalCommands) DeleteK3dCluster(ctx context.Context, name string) error { + output, err := l.runner.RunQuiet(ctx, "k3d", "cluster", "list", "--output", "json") + if err != nil { + return commandFailed("k3d cluster list", err) + } + var clusters []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(output, &clusters); err != nil { + return errors.New("k3d returned an invalid cluster list") + } + registryShared, err := l.demo2RegistryUsedOutsideCluster(ctx, name) + if err != nil { + return err + } + for _, cluster := range clusters { + if cluster.Name == name { + if _, err := l.runner.Run(ctx, "k3d", "cluster", "delete", name); err != nil { + return commandFailed("k3d cluster delete", err) + } + break + } + } + if registryShared { + return nil + } + return l.deleteDemo2Registry(ctx) +} + +func (l *demo2LocalCommands) demo2RegistryUsedOutsideCluster(ctx context.Context, clusterName string) (bool, error) { + containerName, found, err := l.findDemo2Registry(ctx) + if err != nil { + return false, err + } + if !found { + return false, nil + } + + output, err := l.runner.RunQuiet(ctx, "docker", "inspect", containerName) + if err != nil { + return false, commandFailed("docker inspect demo registry", err) + } + var containers []struct { + NetworkSettings struct { + Networks map[string]json.RawMessage `json:"Networks"` + } `json:"NetworkSettings"` + } + if err := json.Unmarshal(output, &containers); err != nil || len(containers) != 1 { + return false, errors.New("docker returned invalid demo registry details") + } + targetNetwork := "k3d-" + clusterName + for network := range containers[0].NetworkSettings.Networks { + if strings.HasPrefix(network, "k3d-") && network != targetNetwork { + return true, nil + } + } + return false, nil +} + +func (l *demo2LocalCommands) deleteDemo2Registry(ctx context.Context) error { + containerName, found, err := l.findDemo2Registry(ctx) + if err != nil { + return err + } + if !found { + return nil + } + if _, err := l.runner.Run(ctx, "k3d", "registry", "delete", containerName); err != nil { + return commandFailed("k3d registry delete", err) + } + return nil +} + +func (l *demo2LocalCommands) TeardownLoopback(ctx context.Context) error { + if l.goos == "darwin" { + output, err := l.runner.RunQuiet(ctx, "ifconfig", "lo0") + if err != nil { + return commandFailed("macOS loopback inspection", err) + } + if !strings.Contains(string(output), demo2NodeIP) { + return nil + } + if _, err := l.runner.Run(ctx, "sudo", "ifconfig", "lo0", "-alias", demo2NodeIP); err != nil { + return commandFailed("macOS loopback removal", err) + } + return nil + } + if l.goos != "linux" { + return nil + } + version, err := os.ReadFile("/proc/version") + if err != nil || !strings.Contains(strings.ToLower(string(version)), "microsoft") { + return nil + } + output, err := l.runner.RunQuiet(ctx, "sudo", "ip", "addr", "show", "dev", "lo") + if err != nil { + return commandFailed("WSL loopback inspection", err) + } + if strings.Contains(string(output), demo2NodeIP) { + if _, err := l.runner.Run(ctx, "sudo", "ip", "addr", "del", demo2NodeIP+"/32", "dev", "lo"); err != nil { + return commandFailed("WSL loopback removal", err) + } + } + powershell := "powershell.exe" + if err := l.runner.LookPath(powershell); err != nil { + powershell = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" + } + command := fmt.Sprintf("Start-Process netsh -Verb RunAs -ArgumentList \"interface ipv4 delete address name='Loopback Pseudo-Interface 1' address=%s\"", demo2NodeIP) + if _, err := l.runner.Run(ctx, powershell, "-NoProfile", "-Command", command); err != nil { + return commandFailed("Windows loopback removal", err) + } + return nil +} diff --git a/cmd/demo2_destroy_test.go b/cmd/demo2_destroy_test.go new file mode 100644 index 00000000..c4539231 --- /dev/null +++ b/cmd/demo2_destroy_test.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDemo2DestroyKeepsQoveryConfigurationByDefault(t *testing.T) { + local := &fakeDemo2DestroyLocal{} + output := &bytes.Buffer{} + orchestrator := demo2DestroyOrchestrator{local: local, out: output} + + err := orchestrator.Destroy(context.Background(), demo2DestroyConfig{ClusterName: "local-demo2-user"}) + + require.NoError(t, err) + assert.True(t, local.clusterDeleted) + assert.True(t, local.loopbackRemoved) + assert.Contains(t, output.String(), "Operator association were preserved") +} + +func TestDemo2DestroyDeletesQoveryConfigurationWhenRequested(t *testing.T) { + local := &fakeDemo2DestroyLocal{} + api := &fakeDemo2DestroyAPI{clusterFound: true} + orchestrator := demo2DestroyOrchestrator{api: api, local: local, out: &bytes.Buffer{}} + + err := orchestrator.Destroy(context.Background(), demo2DestroyConfig{ + OrganizationID: "organization-id", + ClusterName: "local-demo2-user", + DeleteQoveryConfig: true, + }) + + require.NoError(t, err) + assert.Equal(t, "cluster-id", api.deletedClusterID) +} + +func TestDemo2DestroyDeletesRegistryWhenItIsNotShared(t *testing.T) { + runner := &recordingDemo2Runner{outputs: [][]byte{ + []byte(`[{"name":"local-demo2-user"}]`), + []byte(`[{"name":"k3d-qovery-registry.lan"}]`), + []byte(`[{"NetworkSettings":{"Networks":{"k3d-local-demo2-user":{}}}}]`), + nil, + []byte(`[{"name":"k3d-qovery-registry.lan"}]`), + nil, + }} + local := demo2LocalCommands{runner: runner, goos: "linux"} + + err := local.DeleteK3dCluster(context.Background(), "local-demo2-user") + + require.NoError(t, err) + assert.Equal(t, []string{"k3d", "cluster", "delete", "local-demo2-user"}, runner.calls[3]) + assert.Equal(t, []string{"k3d", "registry", "delete", "k3d-" + demo2Registry}, runner.calls[5]) +} + +func TestDemo2DestroyPreservesSharedRegistry(t *testing.T) { + runner := &recordingDemo2Runner{outputs: [][]byte{ + []byte(`[{"name":"local-demo2-user"}]`), + []byte(`[{"name":"k3d-qovery-registry.lan"}]`), + []byte(`[{"NetworkSettings":{"Networks":{"k3d-local-demo2-user":{},"k3d-other-demo":{}}}}]`), + nil, + }} + local := demo2LocalCommands{runner: runner, goos: "linux"} + + err := local.DeleteK3dCluster(context.Background(), "local-demo2-user") + + require.NoError(t, err) + assert.Len(t, runner.calls, 4) + assert.Equal(t, []string{"k3d", "cluster", "delete", "local-demo2-user"}, runner.calls[3]) +} + +func TestDemo2DestroyRemovesMacOSLoopbackAlias(t *testing.T) { + runner := &recordingDemo2Runner{outputs: [][]byte{ + []byte("inet 172.42.0.3 netmask 0xffffffff"), + nil, + }} + local := demo2LocalCommands{runner: runner, goos: "darwin"} + + err := local.TeardownLoopback(context.Background()) + + require.NoError(t, err) + assert.Equal(t, []string{"sudo", "ifconfig", "lo0", "-alias", demo2NodeIP}, runner.calls[1]) +} + +type fakeDemo2DestroyAPI struct { + clusterFound bool + deletedClusterID string +} + +func (f *fakeDemo2DestroyAPI) FindCluster(context.Context, string, string) (string, bool, error) { + return "cluster-id", f.clusterFound, nil +} + +func (f *fakeDemo2DestroyAPI) DeleteClusterConfig(_ context.Context, _ string, clusterID string) error { + f.deletedClusterID = clusterID + return nil +} + +type fakeDemo2DestroyLocal struct { + clusterDeleted bool + loopbackRemoved bool +} + +func (f *fakeDemo2DestroyLocal) CheckDestroyDependencies(context.Context) error { return nil } + +func (f *fakeDemo2DestroyLocal) DeleteK3dCluster(context.Context, string) error { + f.clusterDeleted = true + return nil +} + +func (f *fakeDemo2DestroyLocal) TeardownLoopback(context.Context) error { + f.loopbackRemoved = true + return nil +} diff --git a/cmd/demo2_up.go b/cmd/demo2_up.go new file mode 100644 index 00000000..3e3c9711 --- /dev/null +++ b/cmd/demo2_up.go @@ -0,0 +1,709 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "os/user" + "path" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/qovery/qovery-cli/utils" + qovery "github.com/qovery/qovery-client-go" + "github.com/spf13/cobra" +) + +const ( + demo2K3sImage = "rancher/k3s:v1.33.5-k3s1" + demo2Subnet = "172.42.0.0/16" + demo2NodeIP = "172.42.0.3" + demo2Registry = "qovery-registry.lan" + demo2PlatformTemplateKey = "qovery-demo-v0" +) + +var ( + demo2ClusterName string + demo2Debug bool +) + +var demo2UpCmd = &cobra.Command{ + Use: "up", + Short: "Create an experimental local cluster using Qovery Operator and Engine V2", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + utils.Capture(cmd) + if runtime.GOOS == "windows" { + return errors.New("qovery demo2 is not supported directly on Windows; use WSL") + } + tokenType, token, err := utils.GetAccessToken() + if err != nil { + return fmt.Errorf("authentication failed; run `qovery auth` first: %w", err) + } + organizationID, _, err := utils.CurrentOrganization(true) + if err != nil { + return fmt.Errorf("cannot resolve the current organization: %w", err) + } + if err := validateDemo2ClusterName(demo2ClusterName); err != nil { + return err + } + debugLog, debugLogsPath, err := openDemo2DebugLog() + if err != nil { + return err + } + defer func() { _ = debugLog.Close() }() + + terminalOutput := cmd.OutOrStdout() + runner := &demo2ExecRunner{ + out: terminalOutput, + log: debugLog, + debug: demo2Debug, + } + orchestrator := demo2Orchestrator{ + api: &demo2QoveryAPI{client: utils.GetQoveryClient(tokenType, token)}, + local: &demo2LocalCommands{runner: runner, goos: runtime.GOOS}, + clock: demo2SystemClock{}, + out: io.MultiWriter(terminalOutput, debugLog), + } + err = orchestrator.Up(cmd.Context(), demo2Config{ + OrganizationID: string(organizationID), + ClusterName: demo2ClusterName, + CPUArchitecture: detectArchitecture(), + }) + if err != nil { + _, _ = fmt.Fprintf(debugLog, "\nERROR: %v\n", err) + _ = debugLog.Sync() + uploadErrorLogs(tokenType, token, organizationID, demo2ClusterName, debugLogsPath) + utils.CaptureError(cmd, "qovery demo2 up", err.Error()) + return err + } + utils.CaptureWithEvent(cmd, utils.EndOfExecutionEventName) + return nil + }, +} + +func init() { + demo2ClusterName = "local-demo2-" + demo2SafeUsername() + demo2UpCmd.Flags().StringVarP(&demo2ClusterName, "cluster-name", "c", demo2ClusterName, "The name of the experimental local cluster") + demo2UpCmd.Flags().BoolVar(&demo2Debug, "debug", false, "Enable debug mode") + demo2Cmd.AddCommand(demo2UpCmd) +} + +func openDemo2DebugLog() (*os.File, string, error) { + directory := filepath.Join(os.TempDir(), "qovery-demo") + if err := os.MkdirAll(directory, 0700); err != nil { + return nil, "", fmt.Errorf("cannot create demo log directory: %w", err) + } + logPath := filepath.Join(directory, "qovery-demo.log") + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) + if err != nil { + return nil, "", fmt.Errorf("cannot create demo log file: %w", err) + } + return file, logPath, nil +} + +func demo2SafeUsername() string { + current, err := user.Current() + if err != nil { + return "qovery" + } + value := strings.ToLower(current.Username) + value = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(value, "-") + value = strings.Trim(value, "-") + if value == "" { + return "qovery" + } + return value +} + +func validateDemo2ClusterName(name string) error { + if !regexp.MustCompile(`^[a-zA-Z][-a-zA-Z0-9]*[a-zA-Z0-9]$`).MatchString(name) { + return fmt.Errorf("cluster name must start with a letter, end with a letter or digit, and contain only letters, digits, or hyphens: got %q", name) + } + return nil +} + +type demo2QoveryAPI struct { + client *qovery.APIClient +} + +func (a *demo2QoveryAPI) EnsureOnPremiseCredentials(ctx context.Context, organizationID string) (demo2Credential, error) { + list, _, err := a.client.CloudProviderCredentialsAPI.ListOnPremiseCredentials(ctx, organizationID).Execute() + if err != nil { + return demo2Credential{}, err + } + if results := list.GetResults(); len(results) > 0 { + return demo2CredentialFromQovery(results[0]) + } + created, _, err := a.client.CloudProviderCredentialsAPI.CreateOnPremiseCredentials(ctx, organizationID). + OnPremiseCredentialsRequest(qovery.OnPremiseCredentialsRequest{Name: "on-premise"}). + Execute() + if err != nil { + return demo2Credential{}, err + } + return demo2CredentialFromQovery(*created) +} + +func demo2CredentialFromQovery(value qovery.ClusterCredentials) (demo2Credential, error) { + generic, ok := value.GetActualInstance().(*qovery.GenericClusterCredentials) + if !ok || generic == nil || generic.Id == "" { + return demo2Credential{}, errors.New("qovery returned invalid On-Premise credentials") + } + return demo2Credential{ID: generic.Id, Name: generic.Name}, nil +} + +func (a *demo2QoveryAPI) FindCluster(ctx context.Context, organizationID string, name string) (string, bool, error) { + clusters, _, err := a.client.ClustersAPI.ListOrganizationCluster(ctx, organizationID).Execute() + if err != nil { + return "", false, err + } + cluster := findCluster(clusters.GetResults(), name) + if cluster == nil { + return "", false, nil + } + return cluster.Id, true, nil +} + +func newDemo2ClusterRequest(name string, credential demo2Credential) qovery.ClusterRequest { + production := false + kubernetes := qovery.KUBERNETESENUM_SELF_MANAGED + provider := qovery.CLOUDPROVIDERENUM_ON_PREMISE + region := "unknown" + return qovery.ClusterRequest{ + Name: name, + Region: "on-premise", + CloudProvider: qovery.CLOUDVENDORENUM_ON_PREMISE, + Kubernetes: &kubernetes, + Production: &production, + CloudProviderCredentials: &qovery.ClusterCloudProviderInfoRequest{ + CloudProvider: &provider, + Credentials: &qovery.ClusterCloudProviderInfoCredentials{ + Id: &credential.ID, + Name: &credential.Name, + }, + Region: ®ion, + }, + Features: []qovery.ClusterRequestFeaturesInner{}, + AdditionalProperties: map[string]interface{}{"is_demo": true}, + } +} + +func (a *demo2QoveryAPI) CreateCluster(ctx context.Context, organizationID string, name string, credential demo2Credential) (string, error) { + cluster, _, err := a.client.ClustersAPI.CreateCluster(ctx, organizationID). + ClusterRequest(newDemo2ClusterRequest(name, credential)). + Execute() + if err != nil { + return "", err + } + return cluster.Id, nil +} + +func (a *demo2QoveryAPI) ConfigureOperator(ctx context.Context, organizationID string, clusterID string, cpuArchitecture string) error { + demoBinding, err := a.demoPlatformBinding(ctx, organizationID) + if err != nil { + return err + } + existingBinding, response, err := a.client.PlatformConfigurationAPI.GetClusterPlatformBinding(ctx, organizationID, clusterID).Execute() + if err != nil { + if response == nil || response.StatusCode != http.StatusNotFound { + return err + } + } + binding := selectDemo2PlatformBinding(existingBinding, demoBinding) + request, err := newDemo2OperatorBindingRequest(binding, cpuArchitecture) + if err != nil { + return err + } + _, _, err = a.client.PlatformConfigurationAPI.UpdateClusterPlatformBinding(ctx, organizationID, clusterID). + ClusterPlatformBindingRequest(request). + Execute() + return err +} + +func (a *demo2QoveryAPI) demoPlatformBinding(ctx context.Context, organizationID string) (*qovery.ClusterPlatformBindingResponse, error) { + catalog, _, err := a.client.PlatformConfigurationAPI.ListPlatformTemplates(ctx, organizationID). + ClusterMode(qovery.PLATFORMCLUSTERMODE_CUSTOMER_MANAGED). + CloudProvider(qovery.PLATFORMCLOUDVENDOR_UNKNOWN). + Execute() + if err != nil { + return nil, err + } + return newDemo2PlatformBinding(catalog) +} + +func newDemo2PlatformBinding(catalog *qovery.PlatformTemplateCatalogResponse) (*qovery.ClusterPlatformBindingResponse, error) { + if catalog == nil { + return nil, fmt.Errorf("qovery returned no %q platform template for the local demo", demo2PlatformTemplateKey) + } + var template *qovery.PlatformTemplateSummaryResponse + for i := range catalog.Results { + candidate := &catalog.Results[i] + if candidate.Key == demo2PlatformTemplateKey && strings.TrimSpace(candidate.Version) != "" { + template = candidate + break + } + } + if template == nil { + return nil, fmt.Errorf("qovery returned no %q platform template for the local demo", demo2PlatformTemplateKey) + } + layerSelections := make(map[string]bool) + for _, layer := range template.Layers { + if !layer.Mandatory { + layerSelections[layer.Key] = layer.EnabledByDefault + } + } + return &qovery.ClusterPlatformBindingResponse{ + TemplateKey: template.Key, + TemplateVersion: template.Version, + LayerSelections: layerSelections, + ManagedConfig: map[string]map[string]interface{}{}, + CustomerProvidedInputs: map[string]map[string]string{}, + }, nil +} + +func selectDemo2PlatformBinding( + existingBinding *qovery.ClusterPlatformBindingResponse, + demoBinding *qovery.ClusterPlatformBindingResponse, +) *qovery.ClusterPlatformBindingResponse { + if existingBinding != nil && demoBinding != nil && + existingBinding.TemplateKey == demoBinding.TemplateKey && + existingBinding.TemplateVersion == demoBinding.TemplateVersion { + return existingBinding + } + return demoBinding +} + +func newDemo2OperatorBindingRequest(binding *qovery.ClusterPlatformBindingResponse, cpuArchitecture string) (qovery.ClusterPlatformBindingRequest, error) { + if binding == nil || strings.TrimSpace(binding.TemplateKey) == "" || strings.TrimSpace(binding.TemplateVersion) == "" { + return qovery.ClusterPlatformBindingRequest{}, errors.New("qovery returned an invalid platform binding") + } + architecture := strings.ToUpper(strings.TrimSpace(cpuArchitecture)) + if architecture != "AMD64" && architecture != "ARM64" { + return qovery.ClusterPlatformBindingRequest{}, fmt.Errorf("unsupported local CPU architecture %q", cpuArchitecture) + } + + managedConfig := make(map[string]map[string]interface{}, len(binding.ManagedConfig)+1) + for component, values := range binding.ManagedConfig { + managedConfig[component] = make(map[string]interface{}, len(values)) + for key, value := range values { + managedConfig[component][key] = value + } + } + operatorConfig := managedConfig["qovery-operator"] + if operatorConfig == nil { + operatorConfig = map[string]interface{}{} + } + operatorConfig["cpuArchitectures"] = architecture + managedConfig["qovery-operator"] = operatorConfig + + request := qovery.NewClusterPlatformBindingRequest(binding.TemplateKey, binding.TemplateVersion) + request.SetLayerSelections(binding.LayerSelections) + request.SetManagedConfig(managedConfig) + request.SetCustomerProvidedInputs(binding.CustomerProvidedInputs) + return *request, nil +} + +func (a *demo2QoveryAPI) GetOperatorBootstrap(ctx context.Context, organizationID string, clusterID string) (demo2Bootstrap, error) { + bootstrap, _, err := a.client.ClusterOperatorAPI.GetClusterOperatorBootstrap(ctx, organizationID, clusterID).Execute() + if err != nil { + return demo2Bootstrap{}, err + } + return demo2Bootstrap{ + ReleaseName: bootstrap.ReleaseName, + ChartReference: bootstrap.ChartReference, + ChartVersion: bootstrap.ChartVersion, + Namespace: bootstrap.Namespace, + ValuesYAML: bootstrap.ValuesYaml, + }, nil +} + +func (a *demo2QoveryAPI) AttachOperator(ctx context.Context, organizationID string, clusterID string) error { + _, err := a.client.ClusterOperatorAPI.AttachClusterOperator(ctx, organizationID, clusterID).Execute() + return err +} + +func (a *demo2QoveryAPI) GetOperatorStatus(ctx context.Context, organizationID string, clusterID string) (demo2OperatorStatus, error) { + status, _, err := a.client.ClusterOperatorAPI.GetClusterOperatorStatus(ctx, organizationID, clusterID).Execute() + if err != nil { + return demo2OperatorStatus{}, err + } + return demo2OperatorStatus{ + Connected: status.OperatorConnected, + LastHeartbeat: status.LastHeartbeat.Get(), + }, nil +} + +func (a *demo2QoveryAPI) DeployCluster(ctx context.Context, organizationID string, clusterID string) (string, error) { + status, _, err := a.client.ClustersAPI.DeployCluster(ctx, organizationID, clusterID).Execute() + if err != nil { + return "", err + } + return string(status.Status), nil +} + +func (a *demo2QoveryAPI) GetClusterStatus(ctx context.Context, organizationID string, clusterID string) (string, error) { + status, _, err := a.client.ClustersAPI.GetClusterStatus(ctx, organizationID, clusterID).Execute() + if err != nil { + return "", err + } + return string(status.Status), nil +} + +type demo2CommandRunner interface { + LookPath(string) error + Run(context.Context, string, ...string) ([]byte, error) + RunQuiet(context.Context, string, ...string) ([]byte, error) +} + +type demo2ExecRunner struct { + out io.Writer + log io.Writer + debug bool +} + +func (r *demo2ExecRunner) LookPath(name string) error { + _, err := exec.LookPath(name) + return err +} + +func (r *demo2ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + return r.run(ctx, true, name, args...) +} + +func (r *demo2ExecRunner) RunQuiet(ctx context.Context, name string, args ...string) ([]byte, error) { + return r.run(ctx, false, name, args...) +} + +func (r *demo2ExecRunner) run(ctx context.Context, visible bool, name string, args ...string) ([]byte, error) { + commandLine := strings.Join(append([]string{name}, args...), " ") + if r.log != nil { + _, _ = fmt.Fprintf(r.log, "$ %s\n", commandLine) + } + if (visible || r.debug) && r.out != nil { + _, _ = fmt.Fprintf(r.out, "$ %s\n", commandLine) + } + + var output bytes.Buffer + writers := []io.Writer{&output} + if r.log != nil { + writers = append(writers, r.log) + } + if (visible || r.debug) && r.out != nil { + writers = append(writers, r.out) + } + command := exec.CommandContext(ctx, name, args...) + command.Stdout = io.MultiWriter(writers...) + command.Stderr = command.Stdout + err := command.Run() + if err != nil && !visible && !r.debug && r.out != nil { + _, _ = fmt.Fprintf(r.out, "$ %s\n", commandLine) + _, _ = r.out.Write(output.Bytes()) + } + return output.Bytes(), err +} + +type demo2LocalCommands struct { + runner demo2CommandRunner + goos string + tempDir string +} + +func (l *demo2LocalCommands) CheckDependencies(ctx context.Context) error { + for _, dependency := range []string{"docker", "k3d", "helm", "kubectl"} { + if err := l.runner.LookPath(dependency); err != nil { + return fmt.Errorf("required command %q is not installed", dependency) + } + } + if _, err := l.runner.RunQuiet(ctx, "docker", "info"); err != nil { + return errors.New("docker is not running") + } + return nil +} + +func (l *demo2LocalCommands) EnsureK3dCluster(ctx context.Context, name string) error { + output, err := l.runner.RunQuiet(ctx, "k3d", "cluster", "list", "--output", "json") + if err != nil { + return commandFailed("k3d cluster list", err) + } + var clusters []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(output, &clusters); err != nil { + return errors.New("k3d returned an invalid cluster list") + } + for _, cluster := range clusters { + if cluster.Name == name { + if _, err := l.runner.Run(ctx, "k3d", "cluster", "start", name); err != nil { + return commandFailed("k3d cluster start", err) + } + return l.ensureDemo2Registry(ctx, name) + } + } + args := []string{ + "cluster", "create", name, + "--image", demo2K3sImage, + "--subnet", demo2Subnet, + "--k3s-arg", "--node-ip=" + demo2NodeIP + "@server:0", + "--k3s-arg", "--disable=traefik@server:*", + "--registry-create", demo2Registry, + "--port", "80:80@loadbalancer", + "--port", "443:443@loadbalancer", + } + if _, err := l.runner.Run(ctx, "k3d", args...); err != nil { + return commandFailed("k3d cluster create", err) + } + return l.ensureDemo2Registry(ctx, name) +} + +func (l *demo2LocalCommands) ensureDemo2Registry(ctx context.Context, clusterName string) error { + containerName, found, err := l.findDemo2Registry(ctx) + if err != nil { + return err + } + if found { + return l.ensureDemo2RegistryAlias(ctx, clusterName, containerName) + } + if _, err := l.runner.Run( + ctx, + "k3d", "registry", "create", demo2Registry, + "--default-network", "k3d-"+clusterName, + "--no-help", + ); err != nil { + return commandFailed("k3d registry create", err) + } + containerName, found, err = l.findDemo2Registry(ctx) + if err != nil { + return err + } + if !found { + return errors.New("k3d did not expose the created demo registry") + } + return l.ensureDemo2RegistryAlias(ctx, clusterName, containerName) +} + +func (l *demo2LocalCommands) findDemo2Registry(ctx context.Context) (string, bool, error) { + output, err := l.runner.RunQuiet(ctx, "k3d", "registry", "list", "--output", "json") + if err != nil { + return "", false, commandFailed("k3d registry list", err) + } + var registries []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(output, ®istries); err != nil { + return "", false, errors.New("k3d returned an invalid registry list") + } + for _, registry := range registries { + if registry.Name == demo2Registry || registry.Name == "k3d-"+demo2Registry { + return registry.Name, true, nil + } + } + return "", false, nil +} + +func (l *demo2LocalCommands) ensureDemo2RegistryAlias(ctx context.Context, clusterName string, containerName string) error { + networkName := "k3d-" + clusterName + output, err := l.runner.RunQuiet(ctx, "docker", "inspect", containerName) + if err != nil { + return commandFailed("docker inspect demo registry", err) + } + var containers []struct { + NetworkSettings struct { + Networks map[string]struct { + Aliases []string `json:"Aliases"` + DNSNames []string `json:"DNSNames"` + } `json:"Networks"` + } `json:"NetworkSettings"` + } + if err := json.Unmarshal(output, &containers); err != nil || len(containers) != 1 { + return errors.New("docker returned invalid demo registry details") + } + network, connected := containers[0].NetworkSettings.Networks[networkName] + if connected && (containsString(network.Aliases, demo2Registry) || containsString(network.DNSNames, demo2Registry)) { + return nil + } + if connected { + if _, err := l.runner.Run(ctx, "docker", "network", "disconnect", networkName, containerName); err != nil { + return commandFailed("docker network disconnect demo registry", err) + } + } + if _, err := l.runner.Run( + ctx, + "docker", "network", "connect", "--alias", demo2Registry, networkName, containerName, + ); err != nil { + return commandFailed("docker network connect demo registry", err) + } + return nil +} + +func containsString(values []string, expected string) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} + +func (l *demo2LocalCommands) EnsureLoopback(ctx context.Context) error { + switch l.goos { + case "darwin": + output, err := l.runner.RunQuiet(ctx, "ifconfig", "lo0") + if err != nil { + return commandFailed("macOS loopback inspection", err) + } + if strings.Contains(string(output), demo2NodeIP) { + return nil + } + if _, err := l.runner.Run(ctx, "sudo", "ifconfig", "lo0", "alias", demo2NodeIP+"/32", "up"); err != nil { + return commandFailed("macOS loopback configuration", err) + } + case "linux": + version, err := os.ReadFile("/proc/version") + if err == nil && strings.Contains(strings.ToLower(string(version)), "microsoft") { + output, err := l.runner.Run(ctx, "sudo", "ip", "addr", "add", demo2NodeIP+"/32", "dev", "lo") + if err != nil && !strings.Contains(strings.ToLower(string(output)), "exists") { + return commandFailed("WSL loopback configuration", err) + } + powershell := "powershell.exe" + if err := l.runner.LookPath(powershell); err != nil { + powershell = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" + } + command := fmt.Sprintf("Start-Process netsh -Verb RunAs -ArgumentList \"interface ipv4 add address name='Loopback Pseudo-Interface 1' address=%s mask=255.255.255.255 skipassource=true\"", demo2NodeIP) + output, err = l.runner.Run(ctx, powershell, "-NoProfile", "-Command", command) + if err != nil && !strings.Contains(strings.ToLower(string(output)), "exists") { + return commandFailed("Windows loopback configuration", err) + } + } + } + return nil +} + +func (l *demo2LocalCommands) LegacyQoveryReleaseExists(ctx context.Context) (bool, error) { + output, err := l.runner.RunQuiet(ctx, "helm", "list", "--namespace", "qovery", "--all", "--output", "json", "--filter", "^qovery$") + if err != nil { + return false, commandFailed("helm list", err) + } + var releases []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(output, &releases); err != nil { + return false, errors.New("helm returned an invalid release list") + } + for _, release := range releases { + if release.Name == "qovery" { + return true, nil + } + } + return false, nil +} + +func buildDemo2HelmArgs(bootstrap demo2Bootstrap, valuesPath string) ([]string, error) { + if strings.TrimSpace(bootstrap.ReleaseName) == "" || strings.TrimSpace(bootstrap.ChartReference) == "" || strings.TrimSpace(bootstrap.ChartVersion) == "" || strings.TrimSpace(bootstrap.Namespace) == "" { + return nil, errors.New("operator bootstrap is missing structured Helm fields") + } + if path.Base(strings.TrimSuffix(bootstrap.ChartReference, "/")) == "qovery" { + return nil, errors.New("refusing to install the legacy Qovery umbrella chart") + } + return []string{ + "upgrade", "--install", + bootstrap.ReleaseName, + bootstrap.ChartReference, + "--version", bootstrap.ChartVersion, + "--namespace", bootstrap.Namespace, + "--values", valuesPath, + "--create-namespace", + "--atomic", + "--wait", + "--timeout", "15m", + }, nil +} + +func (l *demo2LocalCommands) InstallOperator(ctx context.Context, bootstrap demo2Bootstrap) error { + file, err := os.CreateTemp(l.tempDir, "qovery-demo2-operator-values-*.yaml") + if err != nil { + return errors.New("cannot create protected temporary Operator values file") + } + path := file.Name() + defer func() { _ = os.Remove(path) }() + if err := file.Chmod(0600); err != nil { + _ = file.Close() + return errors.New("cannot protect temporary Operator values file") + } + if _, err := file.WriteString(bootstrap.ValuesYAML); err != nil { + _ = file.Close() + return errors.New("cannot write temporary Operator values file") + } + if err := file.Close(); err != nil { + return errors.New("cannot close temporary Operator values file") + } + args, err := buildDemo2HelmArgs(bootstrap, path) + if err != nil { + return err + } + if _, err := l.runner.Run(ctx, "helm", args...); err != nil { + return commandFailed("helm upgrade --install", err) + } + return nil +} + +func (l *demo2LocalCommands) ValidateWorkloads(ctx context.Context, namespace string) error { + output, err := l.runner.RunQuiet(ctx, "kubectl", "--namespace", namespace, "get", "deployments", "--output", "json") + if err != nil { + return commandFailed("kubectl get deployments", err) + } + var deployments struct { + Items []struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + } `json:"items"` + } + if err := json.Unmarshal(output, &deployments); err != nil { + return errors.New("kubectl returned an invalid deployment list") + } + operatorFound := false + for _, deployment := range deployments.Items { + switch deployment.Metadata.Name { + case "qovery-operator": + operatorFound = true + case "qovery-engine": + return errors.New("unexpected permanent Deployment qovery-engine exists in namespace qovery") + } + } + if !operatorFound { + return errors.New("deployment qovery-operator was not found after installation") + } + return nil +} + +func commandFailed(operation string, err error) error { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return fmt.Errorf("%s failed with exit code %d", operation, exitErr.ExitCode()) + } + return fmt.Errorf("%s failed", operation) +} + +type demo2SystemClock struct{} + +func (demo2SystemClock) Now() time.Time { return time.Now() } + +func (demo2SystemClock) Sleep(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/cmd/demo2_up_orchestrator.go b/cmd/demo2_up_orchestrator.go new file mode 100644 index 00000000..eae2b622 --- /dev/null +++ b/cmd/demo2_up_orchestrator.go @@ -0,0 +1,229 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" +) + +const ( + demo2OperatorHeartbeatFreshness = 2 * time.Minute + demo2DefaultOperatorTimeout = 10 * time.Minute + demo2DefaultDeploymentTimeout = 90 * time.Minute + demo2DefaultPollInterval = 5 * time.Second +) + +type demo2Credential struct { + ID string + Name string +} + +type demo2Bootstrap struct { + ReleaseName string + ChartReference string + ChartVersion string + Namespace string + ValuesYAML string +} + +type demo2OperatorStatus struct { + Connected bool + LastHeartbeat *time.Time +} + +type demo2API interface { + EnsureOnPremiseCredentials(context.Context, string) (demo2Credential, error) + FindCluster(context.Context, string, string) (string, bool, error) + CreateCluster(context.Context, string, string, demo2Credential) (string, error) + ConfigureOperator(context.Context, string, string, string) error + GetOperatorBootstrap(context.Context, string, string) (demo2Bootstrap, error) + AttachOperator(context.Context, string, string) error + GetOperatorStatus(context.Context, string, string) (demo2OperatorStatus, error) + DeployCluster(context.Context, string, string) (string, error) + GetClusterStatus(context.Context, string, string) (string, error) +} + +type demo2Local interface { + CheckDependencies(context.Context) error + EnsureK3dCluster(context.Context, string) error + EnsureLoopback(context.Context) error + LegacyQoveryReleaseExists(context.Context) (bool, error) + InstallOperator(context.Context, demo2Bootstrap) error + ValidateWorkloads(context.Context, string) error +} + +type demo2Clock interface { + Now() time.Time + Sleep(context.Context, time.Duration) error +} + +type demo2Config struct { + OrganizationID string + ClusterName string + CPUArchitecture string + OperatorTimeout time.Duration + DeploymentTimeout time.Duration + PollInterval time.Duration +} + +type demo2Orchestrator struct { + api demo2API + local demo2Local + clock demo2Clock + out io.Writer +} + +func (o *demo2Orchestrator) Up(ctx context.Context, cfg demo2Config) error { + if cfg.OperatorTimeout <= 0 { + cfg.OperatorTimeout = demo2DefaultOperatorTimeout + } + if cfg.DeploymentTimeout <= 0 { + cfg.DeploymentTimeout = demo2DefaultDeploymentTimeout + } + if cfg.PollInterval <= 0 { + cfg.PollInterval = demo2DefaultPollInterval + } + + o.phase("Checking local dependencies") + if err := o.local.CheckDependencies(ctx); err != nil { + return fmt.Errorf("local dependency check failed: %w", err) + } + + o.phase("Resolving Qovery On-Premise credentials and cluster") + credential, err := o.api.EnsureOnPremiseCredentials(ctx, cfg.OrganizationID) + if err != nil { + return fmt.Errorf("cannot resolve On-Premise credentials: %w", err) + } + clusterID, found, err := o.api.FindCluster(ctx, cfg.OrganizationID, cfg.ClusterName) + if err != nil { + return fmt.Errorf("cannot look up Qovery cluster: %w", err) + } + if !found { + clusterID, err = o.api.CreateCluster(ctx, cfg.OrganizationID, cfg.ClusterName, credential) + if err != nil { + return fmt.Errorf("cannot create Qovery cluster: %w", err) + } + } + + o.phase("Creating or starting the local k3d cluster") + if err := o.local.EnsureK3dCluster(ctx, cfg.ClusterName); err != nil { + return fmt.Errorf("cannot prepare local k3d cluster: %w", err) + } + if err := o.local.EnsureLoopback(ctx); err != nil { + return fmt.Errorf("cannot configure local loopback: %w", err) + } + + o.phase("Checking for an unsupported legacy Qovery release") + legacy, err := o.local.LegacyQoveryReleaseExists(ctx) + if err != nil { + return fmt.Errorf("cannot inspect Helm releases: %w", err) + } + if legacy { + return errors.New("legacy Helm release \"qovery\" exists in namespace \"qovery\"; adopting an old demo is not supported: destroy and recreate the local cluster before running `qovery demo2 up`") + } + + o.phase("Bootstrapping and attaching the Qovery Operator") + if err := o.api.ConfigureOperator(ctx, cfg.OrganizationID, clusterID, cfg.CPUArchitecture); err != nil { + return fmt.Errorf("cannot configure the Qovery Operator for the local demo: %w", err) + } + bootstrap, err := o.api.GetOperatorBootstrap(ctx, cfg.OrganizationID, clusterID) + if err != nil { + return errors.New("cannot retrieve the Qovery Operator bootstrap") + } + if err := o.api.AttachOperator(ctx, cfg.OrganizationID, clusterID); err != nil { + return fmt.Errorf("cannot attach the cluster to the Qovery Operator path: %w", err) + } + if err := o.local.InstallOperator(ctx, bootstrap); err != nil { + return errors.New("operator Helm installation failed; sensitive bootstrap values were redacted") + } + + o.phase("Waiting for a fresh Qovery Operator heartbeat") + if err := o.waitForOperator(ctx, cfg, clusterID); err != nil { + return err + } + + o.phase("Deploying the current self-managed platform catalog") + initialStatus, err := o.api.DeployCluster(ctx, cfg.OrganizationID, clusterID) + if err != nil { + return fmt.Errorf("cannot trigger cluster deployment: %w", err) + } + status, err := o.waitForDeployment(ctx, cfg, clusterID, initialStatus) + if err != nil { + return err + } + + o.phase("Verifying Operator and Engine workloads") + if err := o.local.ValidateWorkloads(ctx, bootstrap.Namespace); err != nil { + return err + } + _, _ = fmt.Fprintf( + o.out, + "\nQovery demo cluster is now installed !!!!\nThe kubeconfig is correctly set, so you can connect to it directly with kubectl or k9s from your local machine.\nTo delete/stop/start your cluster, use k3d cluster xxxx.\n\nGo to https://console.qovery.com to create your first environment on this cluster %q.\nCluster deployment finished with status %s.\n", + cfg.ClusterName, + status, + ) + return nil +} + +func (o *demo2Orchestrator) waitForOperator(ctx context.Context, cfg demo2Config, clusterID string) error { + deadline := o.clock.Now().Add(cfg.OperatorTimeout) + for { + status, err := o.api.GetOperatorStatus(ctx, cfg.OrganizationID, clusterID) + if err != nil { + return fmt.Errorf("cannot read Qovery Operator status: %w", err) + } + if operatorStatusReady(status, o.clock.Now()) { + return nil + } + if !o.clock.Now().Before(deadline) { + return fmt.Errorf("timed out after %s waiting for a connected Qovery Operator with a fresh heartbeat", cfg.OperatorTimeout) + } + if err := o.clock.Sleep(ctx, cfg.PollInterval); err != nil { + return err + } + } +} + +func operatorStatusReady(status demo2OperatorStatus, now time.Time) bool { + if !status.Connected || status.LastHeartbeat == nil { + return false + } + age := now.Sub(*status.LastHeartbeat) + return age >= -30*time.Second && age <= demo2OperatorHeartbeatFreshness +} + +func (o *demo2Orchestrator) waitForDeployment(ctx context.Context, cfg demo2Config, clusterID string, initialStatus string) (string, error) { + deadline := o.clock.Now().Add(cfg.DeploymentTimeout) + status := initialStatus + for { + if status == "DEPLOYED" || status == "RESTARTED" { + return status, nil + } + if isDemo2DeploymentError(status) { + return "", fmt.Errorf("cluster deployment finished with terminal status %s", status) + } + if !o.clock.Now().Before(deadline) { + return "", fmt.Errorf("timed out after %s waiting for cluster deployment; last status was %s", cfg.DeploymentTimeout, status) + } + if err := o.clock.Sleep(ctx, cfg.PollInterval); err != nil { + return "", err + } + var err error + status, err = o.api.GetClusterStatus(ctx, cfg.OrganizationID, clusterID) + if err != nil { + return "", fmt.Errorf("cannot read cluster deployment status: %w", err) + } + } +} + +func isDemo2DeploymentError(status string) bool { + return strings.HasSuffix(status, "_ERROR") || status == "INVALID_CREDENTIALS" || status == "CANCELED" || status == "STOPPED" || status == "DELETED" +} + +func (o *demo2Orchestrator) phase(message string) { + const separator = `""""""""""""""""""""""""""""""""""""""""""""` + _, _ = fmt.Fprintf(o.out, "\n%s\n%s\n%s\n", separator, message, separator) +} diff --git a/cmd/demo2_up_test.go b/cmd/demo2_up_test.go new file mode 100644 index 00000000..07b6fb25 --- /dev/null +++ b/cmd/demo2_up_test.go @@ -0,0 +1,665 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "runtime" + "strings" + "testing" + "time" + + qovery "github.com/qovery/qovery-client-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDemo2UpFreshCreationOrder(t *testing.T) { + now := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + events := []string{} + api := &fakeDemo2API{ + events: &events, + operatorStatuses: []demo2OperatorStatus{readyDemo2OperatorStatus(now)}, + clusterStatuses: []string{"READY", "DEPLOYED"}, + } + local := &fakeDemo2Local{events: &events} + orchestrator := demo2Orchestrator{api: api, local: local, clock: &fakeDemo2Clock{now: now}, out: &bytes.Buffer{}} + + err := orchestrator.Up(context.Background(), testDemo2Config()) + + require.NoError(t, err) + assert.Equal(t, []string{ + "dependencies", "credentials", "find-cluster", "create-cluster", "k3d", "loopback", + "legacy-release", "operator-config", "bootstrap", "attach", "install-operator", "operator-status", + "deploy", "cluster-status", "cluster-status", "validate-workloads", + }, events) + assert.Equal(t, "ARM64", api.operatorCPUArchitecture) +} + +func TestDemo2UpRerunReusesResources(t *testing.T) { + now := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + events := []string{} + api := &fakeDemo2API{ + events: &events, + clusterFound: true, + operatorStatuses: []demo2OperatorStatus{readyDemo2OperatorStatus(now)}, + clusterStatuses: []string{"RESTARTED"}, + } + local := &fakeDemo2Local{events: &events} + orchestrator := demo2Orchestrator{api: api, local: local, clock: &fakeDemo2Clock{now: now}, out: &bytes.Buffer{}} + + err := orchestrator.Up(context.Background(), testDemo2Config()) + + require.NoError(t, err) + assert.NotContains(t, events, "create-cluster") + assert.Contains(t, events, "install-operator") + assert.Contains(t, events, "deploy") + assert.Equal(t, 1, api.ensureCredentialsCalls) +} + +func TestDemo2ClusterRequestSerializesIsDemo(t *testing.T) { + request := newDemo2ClusterRequest("local-demo2-user", demo2Credential{ID: "credential-id", Name: "on-premise"}) + + payload, err := json.Marshal(request) + + require.NoError(t, err) + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(payload, &decoded)) + assert.Equal(t, true, decoded["is_demo"]) + assert.Equal(t, "ON_PREMISE", decoded["cloud_provider"]) + assert.Equal(t, "SELF_MANAGED", decoded["kubernetes"]) + assert.Equal(t, false, decoded["production"]) +} + +func TestDemo2OperatorBindingPreservesExistingConfiguration(t *testing.T) { + binding := &qovery.ClusterPlatformBindingResponse{ + TemplateKey: demo2PlatformTemplateKey, + TemplateVersion: "0.1.0", + LayerSelections: map[string]bool{}, + ManagedConfig: map[string]map[string]interface{}{ + "qovery-operator": {"existing": "value"}, + }, + CustomerProvidedInputs: map[string]map[string]string{}, + } + + request, err := newDemo2OperatorBindingRequest(binding, "arm64") + + require.NoError(t, err) + assert.Empty(t, request.GetLayerSelections()) + assert.Equal(t, "value", request.GetManagedConfig()["qovery-operator"]["existing"]) + assert.Equal(t, "ARM64", request.GetManagedConfig()["qovery-operator"]["cpuArchitectures"]) + assert.Empty(t, request.GetCustomerProvidedInputs()) +} + +func TestDemo2PlatformBindingSelectsTheDemoTemplate(t *testing.T) { + catalog := qovery.NewPlatformTemplateCatalogResponse([]qovery.PlatformTemplateSummaryResponse{ + { + Key: "qovery-cluster-v0", + Version: "0.1.0", + }, + { + Key: demo2PlatformTemplateKey, + Version: "0.2.0", + Layers: []qovery.PlatformTemplateLayerResponse{ + {Key: "qovery-stack", Mandatory: false, EnabledByDefault: true}, + {Key: "dns-certificates", Mandatory: true, EnabledByDefault: true}, + }, + }, + }) + + binding, err := newDemo2PlatformBinding(catalog) + + require.NoError(t, err) + assert.Equal(t, demo2PlatformTemplateKey, binding.TemplateKey) + assert.Equal(t, "0.2.0", binding.TemplateVersion) + assert.Equal(t, map[string]bool{"qovery-stack": true}, binding.LayerSelections) + assert.Empty(t, binding.ManagedConfig) + assert.Empty(t, binding.CustomerProvidedInputs) +} + +func TestDemo2PlatformBindingRequiresTheDemoTemplate(t *testing.T) { + catalog := qovery.NewPlatformTemplateCatalogResponse([]qovery.PlatformTemplateSummaryResponse{{ + Key: "qovery-cluster-v0", + Version: "0.1.0", + }}) + + _, err := newDemo2PlatformBinding(catalog) + + require.EqualError(t, err, `qovery returned no "qovery-demo-v0" platform template for the local demo`) +} + +func TestSelectDemo2PlatformBindingPreservesMatchingBinding(t *testing.T) { + existing := &qovery.ClusterPlatformBindingResponse{ + TemplateKey: demo2PlatformTemplateKey, + TemplateVersion: "0.1.0", + ManagedConfig: map[string]map[string]interface{}{"qovery-operator": {"existing": "value"}}, + } + desired := &qovery.ClusterPlatformBindingResponse{ + TemplateKey: demo2PlatformTemplateKey, + TemplateVersion: "0.1.0", + } + + selected := selectDemo2PlatformBinding(existing, desired) + + assert.Same(t, existing, selected) +} + +func TestSelectDemo2PlatformBindingReplacesStandardBinding(t *testing.T) { + existing := &qovery.ClusterPlatformBindingResponse{ + TemplateKey: "qovery-cluster-v0", + TemplateVersion: "0.1.0", + LayerSelections: map[string]bool{"log-infra": false}, + } + desired := &qovery.ClusterPlatformBindingResponse{ + TemplateKey: demo2PlatformTemplateKey, + TemplateVersion: "0.1.0", + LayerSelections: map[string]bool{}, + } + + selected := selectDemo2PlatformBinding(existing, desired) + + assert.Same(t, desired, selected) + assert.NotContains(t, selected.LayerSelections, "log-infra") +} + +func TestDemo2UpRejectsLegacyQoveryRelease(t *testing.T) { + events := []string{} + api := &fakeDemo2API{events: &events} + local := &fakeDemo2Local{events: &events, legacyRelease: true} + orchestrator := demo2Orchestrator{api: api, local: local, clock: &fakeDemo2Clock{now: time.Now()}, out: &bytes.Buffer{}} + + err := orchestrator.Up(context.Background(), testDemo2Config()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "adopting an old demo is not supported") + assert.NotContains(t, events, "bootstrap") + assert.NotContains(t, events, "install-operator") +} + +func TestBuildDemo2HelmArgsUsesStructuredBootstrap(t *testing.T) { + bootstrap := testDemo2Bootstrap() + + args, err := buildDemo2HelmArgs(bootstrap, "/tmp/protected-values.yaml") + + require.NoError(t, err) + assert.Equal(t, []string{ + "upgrade", "--install", "qovery-operator", "oci://registry.example/qovery-operator", + "--version", "1.2.3", "--namespace", "qovery", "--values", "/tmp/protected-values.yaml", + "--create-namespace", "--atomic", "--wait", "--timeout", "15m", + }, args) + assert.NotContains(t, strings.Join(args, " "), "ignored helm command") +} + +func TestBuildDemo2HelmArgsRejectsUmbrellaChart(t *testing.T) { + bootstrap := testDemo2Bootstrap() + bootstrap.ChartReference = "oci://public.ecr.aws/example/charts/qovery" + + _, err := buildDemo2HelmArgs(bootstrap, "/tmp/protected-values.yaml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "umbrella chart") +} + +func TestDemo2OperatorValuesFileIsProtectedAndRemoved(t *testing.T) { + runner := &inspectingDemo2Runner{t: t} + local := demo2LocalCommands{runner: runner, goos: "linux", tempDir: t.TempDir()} + + err := local.InstallOperator(context.Background(), testDemo2Bootstrap()) + + require.NoError(t, err) + require.NotEmpty(t, runner.valuesPath) + _, statErr := os.Stat(runner.valuesPath) + assert.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestDemo2ExecRunnerKeepsCommandOutputInTheDebugLog(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("demo2 is not supported directly on Windows") + } + var terminal bytes.Buffer + var log bytes.Buffer + runner := demo2ExecRunner{out: &terminal, log: &log} + + _, err := runner.RunQuiet(context.Background(), "/bin/sh", "-c", "printf diagnostic >&2; exit 7") + + require.Error(t, err) + assert.Contains(t, terminal.String(), "diagnostic") + assert.Contains(t, log.String(), "$ /bin/sh -c") + assert.Contains(t, log.String(), "diagnostic") +} + +func TestDemo2ExecRunnerStreamsSuccessfulCommandsInDebugMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("demo2 is not supported directly on Windows") + } + var terminal bytes.Buffer + var log bytes.Buffer + runner := demo2ExecRunner{out: &terminal, log: &log, debug: true} + + _, err := runner.RunQuiet(context.Background(), "/bin/sh", "-c", "printf diagnostic") + + require.NoError(t, err) + assert.Contains(t, terminal.String(), "$ /bin/sh -c") + assert.Contains(t, terminal.String(), "diagnostic") + assert.Contains(t, log.String(), "diagnostic") +} + +func TestDemo2ExecRunnerStreamsActionCommands(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("demo2 is not supported directly on Windows") + } + var terminal bytes.Buffer + var log bytes.Buffer + runner := demo2ExecRunner{out: &terminal, log: &log} + + _, err := runner.Run(context.Background(), "/bin/sh", "-c", "printf diagnostic") + + require.NoError(t, err) + assert.Contains(t, terminal.String(), "$ /bin/sh -c") + assert.Contains(t, terminal.String(), "diagnostic") + assert.Contains(t, log.String(), "diagnostic") +} + +func TestDemo2UpTimesOutWaitingForOperator(t *testing.T) { + clock := &fakeDemo2Clock{now: time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)} + api := &fakeDemo2API{operatorStatuses: []demo2OperatorStatus{{Connected: false}}} + local := &fakeDemo2Local{} + orchestrator := demo2Orchestrator{api: api, local: local, clock: clock, out: &bytes.Buffer{}} + cfg := testDemo2Config() + cfg.OperatorTimeout = 2 * time.Second + cfg.PollInterval = time.Second + + err := orchestrator.Up(context.Background(), cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out") + assert.Equal(t, 0, api.deployCalls) +} + +func TestDemo2UpDoesNotDeployBeforeHeartbeat(t *testing.T) { + now := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + events := []string{} + api := &fakeDemo2API{ + events: &events, + operatorStatuses: []demo2OperatorStatus{ + {Connected: true}, + readyDemo2OperatorStatus(now.Add(time.Second)), + }, + clusterStatuses: []string{"DEPLOYED"}, + } + orchestrator := demo2Orchestrator{api: api, local: &fakeDemo2Local{events: &events}, clock: &fakeDemo2Clock{now: now}, out: &bytes.Buffer{}} + cfg := testDemo2Config() + cfg.PollInterval = time.Second + + err := orchestrator.Up(context.Background(), cfg) + + require.NoError(t, err) + firstStatus := indexOf(events, "operator-status") + secondStatus := indexOf(events[firstStatus+1:], "operator-status") + firstStatus + 1 + deploy := indexOf(events, "deploy") + assert.Greater(t, deploy, secondStatus) +} + +func TestDemo2UpDeploymentSucceedsOnlyOnDeployed(t *testing.T) { + now := time.Now() + api := &fakeDemo2API{ + operatorStatuses: []demo2OperatorStatus{readyDemo2OperatorStatus(now)}, + clusterStatuses: []string{"READY", "DEPLOYING", "DEPLOYED"}, + } + orchestrator := demo2Orchestrator{api: api, local: &fakeDemo2Local{}, clock: &fakeDemo2Clock{now: now}, out: &bytes.Buffer{}} + + err := orchestrator.Up(context.Background(), testDemo2Config()) + + require.NoError(t, err) + assert.Equal(t, 3, api.clusterStatusCalls) +} + +func TestDemo2UpReturnsTerminalDeploymentError(t *testing.T) { + now := time.Now() + api := &fakeDemo2API{ + operatorStatuses: []demo2OperatorStatus{readyDemo2OperatorStatus(now)}, + clusterStatuses: []string{"DEPLOYING", "DEPLOYMENT_ERROR"}, + } + orchestrator := demo2Orchestrator{api: api, local: &fakeDemo2Local{}, clock: &fakeDemo2Clock{now: now}, out: &bytes.Buffer{}} + + err := orchestrator.Up(context.Background(), testDemo2Config()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "DEPLOYMENT_ERROR") +} + +func TestDemo2UpRedactsValuesFromErrors(t *testing.T) { + secret := "super-secret-cluster-jwt" + api := &fakeDemo2API{bootstrap: demo2Bootstrap{ + ReleaseName: "qovery-operator", ChartReference: "chart", ChartVersion: "1", Namespace: "qovery", ValuesYAML: "token: " + secret, + }} + local := &fakeDemo2Local{installErr: errors.New("helm failed with values_yaml token: " + secret)} + orchestrator := demo2Orchestrator{api: api, local: local, clock: &fakeDemo2Clock{now: time.Now()}, out: &bytes.Buffer{}} + + err := orchestrator.Up(context.Background(), testDemo2Config()) + + require.Error(t, err) + assert.NotContains(t, err.Error(), secret) + assert.NotContains(t, err.Error(), "values_yaml") + assert.Contains(t, err.Error(), "redacted") +} + +func TestDemo2EnsureK3dClusterUsesPinnedSubstrate(t *testing.T) { + runner := &recordingDemo2Runner{outputs: [][]byte{ + []byte("[]"), + nil, + []byte(`[{"name":"qovery-registry.lan"}]`), + []byte(`[{"NetworkSettings":{"Networks":{"k3d-local-demo2-user":{"DNSNames":["qovery-registry.lan"]}}}}]`), + }} + local := demo2LocalCommands{runner: runner, goos: "linux"} + + err := local.EnsureK3dCluster(context.Background(), "local-demo2-user") + + require.NoError(t, err) + require.Len(t, runner.calls, 4) + assert.Equal(t, "k3d", runner.calls[1][0]) + joined := strings.Join(runner.calls[1][1:], " ") + assert.Contains(t, joined, demo2K3sImage) + assert.Contains(t, joined, demo2Subnet) + assert.Contains(t, joined, "--node-ip="+demo2NodeIP+"@server:0") + assert.Contains(t, joined, "--disable=traefik@server:*") + assert.Contains(t, joined, demo2Registry) + assert.Contains(t, joined, "80:80@loadbalancer") + assert.Contains(t, joined, "443:443@loadbalancer") +} + +func TestDemo2EnsureK3dClusterStartsExistingCluster(t *testing.T) { + runner := &recordingDemo2Runner{outputs: [][]byte{ + []byte(`[{"name":"local-demo2-user"}]`), + nil, + []byte(`[{"name":"k3d-qovery-registry.lan"}]`), + []byte(`[{"NetworkSettings":{"Networks":{"k3d-local-demo2-user":{"DNSNames":["qovery-registry.lan"]}}}}]`), + }} + local := demo2LocalCommands{runner: runner, goos: "linux"} + + err := local.EnsureK3dCluster(context.Background(), "local-demo2-user") + + require.NoError(t, err) + assert.Equal(t, []string{"k3d", "cluster", "start", "local-demo2-user"}, runner.calls[1]) + assert.Equal(t, []string{"k3d", "registry", "list", "--output", "json"}, runner.calls[2]) +} + +func TestDemo2EnsureK3dClusterRecreatesMissingRegistry(t *testing.T) { + runner := &recordingDemo2Runner{outputs: [][]byte{ + []byte(`[{"name":"local-demo2-user"}]`), + nil, + []byte(`[]`), + nil, + []byte(`[{"name":"k3d-qovery-registry.lan"}]`), + []byte(`[{"NetworkSettings":{"Networks":{"k3d-local-demo2-user":{"DNSNames":["k3d-qovery-registry.lan"]}}}}]`), + nil, + nil, + }} + local := demo2LocalCommands{runner: runner, goos: "linux"} + + err := local.EnsureK3dCluster(context.Background(), "local-demo2-user") + + require.NoError(t, err) + assert.Equal(t, []string{ + "k3d", "registry", "create", demo2Registry, + "--default-network", "k3d-local-demo2-user", + "--no-help", + }, runner.calls[3]) + assert.Equal(t, []string{ + "docker", "network", "connect", "--alias", demo2Registry, + "k3d-local-demo2-user", "k3d-" + demo2Registry, + }, runner.calls[7]) +} + +func TestDemo2ValidateWorkloadsRequiresOperatorAndRejectsPermanentEngine(t *testing.T) { + t.Run("valid", func(t *testing.T) { + runner := &recordingDemo2Runner{outputs: [][]byte{[]byte(`{"items":[{"metadata":{"name":"qovery-operator"}}]}`)}} + local := demo2LocalCommands{runner: runner, goos: "linux"} + require.NoError(t, local.ValidateWorkloads(context.Background(), "qovery")) + }) + + t.Run("permanent engine", func(t *testing.T) { + runner := &recordingDemo2Runner{outputs: [][]byte{[]byte(`{"items":[{"metadata":{"name":"qovery-operator"}},{"metadata":{"name":"qovery-engine"}}]}`)}} + local := demo2LocalCommands{runner: runner, goos: "linux"} + err := local.ValidateWorkloads(context.Background(), "qovery") + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected permanent Deployment qovery-engine") + }) +} + +func testDemo2Config() demo2Config { + return demo2Config{ + OrganizationID: "organization-id", + ClusterName: "local-demo2-user", + CPUArchitecture: "ARM64", + OperatorTimeout: time.Minute, + DeploymentTimeout: time.Minute, + PollInterval: time.Second, + } +} + +func testDemo2Bootstrap() demo2Bootstrap { + return demo2Bootstrap{ + ReleaseName: "qovery-operator", + ChartReference: "oci://registry.example/qovery-operator", + ChartVersion: "1.2.3", + Namespace: "qovery", + ValuesYAML: "secretToken: highly-sensitive\n", + } +} + +func readyDemo2OperatorStatus(now time.Time) demo2OperatorStatus { + heartbeat := now + return demo2OperatorStatus{Connected: true, LastHeartbeat: &heartbeat} +} + +type fakeDemo2API struct { + events *[]string + clusterFound bool + bootstrap demo2Bootstrap + operatorStatuses []demo2OperatorStatus + clusterStatuses []string + ensureCredentialsCalls int + operatorStatusCalls int + clusterStatusCalls int + deployCalls int + deployStatus string + operatorCPUArchitecture string +} + +func (f *fakeDemo2API) event(value string) { + if f.events != nil { + *f.events = append(*f.events, value) + } +} + +func (f *fakeDemo2API) EnsureOnPremiseCredentials(context.Context, string) (demo2Credential, error) { + f.event("credentials") + f.ensureCredentialsCalls++ + return demo2Credential{ID: "credential-id", Name: "on-premise"}, nil +} + +func (f *fakeDemo2API) FindCluster(context.Context, string, string) (string, bool, error) { + f.event("find-cluster") + return "cluster-id", f.clusterFound, nil +} + +func (f *fakeDemo2API) CreateCluster(context.Context, string, string, demo2Credential) (string, error) { + f.event("create-cluster") + return "cluster-id", nil +} + +func (f *fakeDemo2API) ConfigureOperator(_ context.Context, _ string, _ string, cpuArchitecture string) error { + f.event("operator-config") + f.operatorCPUArchitecture = cpuArchitecture + return nil +} + +func (f *fakeDemo2API) GetOperatorBootstrap(context.Context, string, string) (demo2Bootstrap, error) { + f.event("bootstrap") + if f.bootstrap.ReleaseName != "" { + return f.bootstrap, nil + } + return testDemo2Bootstrap(), nil +} + +func (f *fakeDemo2API) AttachOperator(context.Context, string, string) error { + f.event("attach") + return nil +} + +func (f *fakeDemo2API) GetOperatorStatus(context.Context, string, string) (demo2OperatorStatus, error) { + f.event("operator-status") + index := f.operatorStatusCalls + f.operatorStatusCalls++ + if len(f.operatorStatuses) == 0 { + return demo2OperatorStatus{}, nil + } + if index >= len(f.operatorStatuses) { + index = len(f.operatorStatuses) - 1 + } + return f.operatorStatuses[index], nil +} + +func (f *fakeDemo2API) DeployCluster(context.Context, string, string) (string, error) { + f.event("deploy") + f.deployCalls++ + if f.deployStatus == "" { + return "DEPLOYMENT_QUEUED", nil + } + return f.deployStatus, nil +} + +func (f *fakeDemo2API) GetClusterStatus(context.Context, string, string) (string, error) { + f.event("cluster-status") + index := f.clusterStatusCalls + f.clusterStatusCalls++ + if len(f.clusterStatuses) == 0 { + return "DEPLOYED", nil + } + if index >= len(f.clusterStatuses) { + index = len(f.clusterStatuses) - 1 + } + return f.clusterStatuses[index], nil +} + +type fakeDemo2Local struct { + events *[]string + legacyRelease bool + installErr error +} + +func (f *fakeDemo2Local) event(value string) { + if f.events != nil { + *f.events = append(*f.events, value) + } +} + +func (f *fakeDemo2Local) CheckDependencies(context.Context) error { + f.event("dependencies") + return nil +} + +func (f *fakeDemo2Local) EnsureK3dCluster(context.Context, string) error { + f.event("k3d") + return nil +} + +func (f *fakeDemo2Local) EnsureLoopback(context.Context) error { + f.event("loopback") + return nil +} + +func (f *fakeDemo2Local) LegacyQoveryReleaseExists(context.Context) (bool, error) { + f.event("legacy-release") + return f.legacyRelease, nil +} + +func (f *fakeDemo2Local) InstallOperator(context.Context, demo2Bootstrap) error { + f.event("install-operator") + return f.installErr +} + +func (f *fakeDemo2Local) ValidateWorkloads(context.Context, string) error { + f.event("validate-workloads") + return nil +} + +type fakeDemo2Clock struct { + now time.Time +} + +func (f *fakeDemo2Clock) Now() time.Time { return f.now } + +func (f *fakeDemo2Clock) Sleep(ctx context.Context, duration time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + f.now = f.now.Add(duration) + return nil + } +} + +type inspectingDemo2Runner struct { + t *testing.T + valuesPath string +} + +func (r *inspectingDemo2Runner) LookPath(string) error { return nil } + +func (r *inspectingDemo2Runner) RunQuiet(ctx context.Context, name string, args ...string) ([]byte, error) { + return r.Run(ctx, name, args...) +} + +func (r *inspectingDemo2Runner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + require.Equal(r.t, "helm", name) + index := indexOf(args, "--values") + require.GreaterOrEqual(r.t, index, 0) + require.Greater(r.t, len(args), index+1) + r.valuesPath = args[index+1] + info, err := os.Stat(r.valuesPath) + require.NoError(r.t, err) + assert.Equal(r.t, os.FileMode(0600), info.Mode().Perm()) + content, err := os.ReadFile(r.valuesPath) + require.NoError(r.t, err) + assert.Equal(r.t, testDemo2Bootstrap().ValuesYAML, string(content)) + return nil, nil +} + +type recordingDemo2Runner struct { + calls [][]string + outputs [][]byte + errors []error +} + +func (r *recordingDemo2Runner) LookPath(string) error { return nil } + +func (r *recordingDemo2Runner) RunQuiet(ctx context.Context, name string, args ...string) ([]byte, error) { + return r.Run(ctx, name, args...) +} + +func (r *recordingDemo2Runner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + r.calls = append(r.calls, append([]string{name}, args...)) + index := len(r.calls) - 1 + var output []byte + var err error + if index < len(r.outputs) { + output = r.outputs[index] + } + if index < len(r.errors) { + err = r.errors[index] + } + return output, err +} + +func indexOf[T comparable](values []T, target T) int { + for index, value := range values { + if value == target { + return index + } + } + return -1 +} diff --git a/go.mod b/go.mod index 690e55fa..8eedf191 100644 --- a/go.mod +++ b/go.mod @@ -24,11 +24,11 @@ require ( github.com/pkg/errors v0.9.1 github.com/posthog/posthog-go v1.12.5 github.com/pterm/pterm v0.12.83 - github.com/qovery/qovery-client-go v0.0.0-20260625132707-e611218d15ba + github.com/qovery/qovery-client-go v0.0.0-20260818114249-c192e6bbd3f6 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.0 github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346 github.com/xlab/treeprint v1.2.0 golang.org/x/sys v0.44.0 diff --git a/go.sum b/go.sum index 40f0661b..b6e1efb3 100644 --- a/go.sum +++ b/go.sum @@ -189,8 +189,8 @@ github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQr github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= -github.com/qovery/qovery-client-go v0.0.0-20260625132707-e611218d15ba h1:J+LKk+v6XfbsDfUg8x6RXXVrCP8sd/hG5xYskxSRwUM= -github.com/qovery/qovery-client-go v0.0.0-20260625132707-e611218d15ba/go.mod h1:mcXeQtxR4AIGIBaWLhy52S16UwL8/1fcDywDuSK1BZ4= +github.com/qovery/qovery-client-go v0.0.0-20260818114249-c192e6bbd3f6 h1:Sj2Yt35vMWuczVTNrhIcn5/BHxV/3GSdSJD2hdVg2Go= +github.com/qovery/qovery-client-go v0.0.0-20260818114249-c192e6bbd3f6/go.mod h1:3vYJdgsBMMo09JbbIvte7UyQuH43Y5GGP2O3uptQfIU= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -210,16 +210,16 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346 h1:TvtdmeYsYEij78hS4oxnwikoiLdIrgav3BA+CbhaDAI= github.com/tonistiigi/go-rosetta v0.0.0-20220804170347-3f4430f2d346/go.mod h1:xKQhd7snlzKFuUi1taTGWjpRE8iFTA06DeacYi3CVFQ= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=