diff --git a/CHANGELOG.md b/CHANGELOG.md index 605f04b2c..987271333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project's packages adheres to [Semantic Versioning](http://semver.org/s ## [Unreleased] +### Added + +- Add `kubectl gs deploy` command for managing Giant Swarm app and config repository deployments with the following features: + - Deploy and undeploy apps from catalogs with version selection + - Deploy and undeploy config repositories (GitRepository resources) + - Interactive mode with fuzzy finding for catalog, app, and version selection using bubbletea UI + - List operations: apps, versions, configs, and catalogs + - Status monitoring for all kustomizations, config repositories, and apps on the cluster + - `--sync` flag for synchronous deployments with automatic Flux reconciliation + - `--undeploy-on-exit` flag to automatically restore previous state on interrupt (Ctrl+C) + - Rich terminal UI with color-coded status indicators and formatted tables + - Support for custom namespaces and catalogs + ### Changed - Update `github.com/3th1nk/cidr` to v0.3.0, adapt internal CIDR mask size calculation in the `tempalte cluster` command. @@ -1617,9 +1630,6 @@ This release supports rendering for CRs: - `AppCatalog` - `App` -<<<<<<< HEAD -[Unreleased]: https://github.com/giantswarm/kubectl-gs/compare/v2.29.4...HEAD -======= [Unreleased]: https://github.com/giantswarm/kubectl-gs/compare/v4.8.1...HEAD [4.8.1]: https://github.com/giantswarm/kubectl-gs/compare/v4.8.0...v4.8.1 [4.8.0]: https://github.com/giantswarm/kubectl-gs/compare/v4.7.1...v4.8.0 @@ -1682,7 +1692,6 @@ This release supports rendering for CRs: [2.31.0]: https://github.com/giantswarm/kubectl-gs/compare/v2.30.0...v2.31.0 [2.30.0]: https://github.com/giantswarm/kubectl-gs/compare/v2.29.5...v2.30.0 [2.29.5]: https://github.com/giantswarm/kubectl-gs/compare/v2.29.4...v2.29.5 ->>>>>>> 6db3ec168ee4518aa472b58f232bcbb60e4490e8 [2.29.4]: https://github.com/giantswarm/kubectl-gs/compare/v2.29.3...v2.29.4 [2.29.3]: https://github.com/giantswarm/kubectl-gs/compare/v2.29.2...v2.29.3 [2.29.2]: https://github.com/giantswarm/kubectl-gs/compare/v2.29.1...v2.29.2 diff --git a/cmd/deploy/app.go b/cmd/deploy/app.go new file mode 100644 index 000000000..765a39b2f --- /dev/null +++ b/cmd/deploy/app.go @@ -0,0 +1,339 @@ +package deploy + +import ( + "context" + "fmt" + "strings" + + applicationv1alpha1 "github.com/giantswarm/apiextensions-application/api/v1alpha1" + + "github.com/giantswarm/kubectl-gs/v5/pkg/data/domain/app" +) + +func (r *runner) deployApp(ctx context.Context, spec *resourceSpec) error { + // Determine the resource name (can be overridden by --name flag) + resourceName := spec.name + if r.flag.Name != "" { + resourceName = r.flag.Name + } + + // Try to get existing app to determine if we need to create or update + existingApp, err := r.appService.GetApp(ctx, r.flag.Namespace, resourceName) + if err != nil { + // App doesn't exist, create it + if app.IsNotFound(err) { + var createdApp *applicationv1alpha1.App + err = RunWithSpinner(fmt.Sprintf("Deploying app %s@%s", spec.name, spec.version), func() error { + createOptions := app.CreateOptions{ + Name: resourceName, + Namespace: r.flag.Namespace, + AppName: spec.name, + AppNamespace: r.flag.Namespace, + AppCatalog: r.flag.Catalog, + AppVersion: spec.version, + } + + var createErr error + createdApp, createErr = r.appService.Create(ctx, createOptions) + return createErr + }) + + if app.IsNoResources(err) { + return fmt.Errorf("no app with the name %s and the version %s found in the catalog", spec.name, spec.version) + } else if err != nil { + return err + } + + // Trigger flux reconciliation if --sync flag is set + if err := r.reconcileFluxApp(ctx, resourceName, r.flag.Namespace); err != nil { + return err + } + + output := DeployOutput(strings.ToLower(createdApp.Kind), resourceName, spec.version, r.flag.Namespace) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + + // Show reminder last if not using --undeploy-on-exit + if !r.flag.UndeployOnExit { + if _, err := fmt.Fprint(r.stdout, ReminderOutput("app", resourceName)); err != nil { + return err + } + } + + return nil + } + return err + } + + // App exists, use the app service to patch it with version validation + var state []string + err = RunWithSpinner(fmt.Sprintf("Updating app %s to version %s", resourceName, spec.version), func() error { + patchOptions := app.PatchOptions{ + Namespace: r.flag.Namespace, + Name: resourceName, + Version: spec.version, + SuspendReconciliation: true, + } + + var patchErr error + state, patchErr = r.appService.Patch(ctx, patchOptions) + return patchErr + }) + + if app.IsNotFound(err) { + return fmt.Errorf("app %s not found in namespace %s", resourceName, r.flag.Namespace) + } else if app.IsNoResources(err) { + return fmt.Errorf("no app with the name %s and the version %s found in the catalog", spec.name, spec.version) + } else if err != nil { + return err + } + + // Trigger flux reconciliation if --sync flag is set + if err := r.reconcileFluxApp(ctx, resourceName, r.flag.Namespace); err != nil { + return err + } + + output := UpdateOutput(strings.ToLower(existingApp.Kind), resourceName, r.flag.Namespace, state) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + + // Show reminder last if not using --undeploy-on-exit + if !r.flag.UndeployOnExit { + if _, err := fmt.Fprint(r.stdout, ReminderOutput("app", resourceName)); err != nil { + return err + } + } + + return nil +} + +func (r *runner) undeployApp(ctx context.Context, spec *resourceSpec) error { + // Get the app first to retrieve its Kind + appResource, err := r.appService.GetApp(ctx, r.flag.Namespace, spec.name) + if err != nil { + if app.IsNotFound(err) { + return fmt.Errorf("app %s not found in namespace %s", spec.name, r.flag.Namespace) + } + return err + } + + var state []string + err = RunWithSpinner(fmt.Sprintf("Undeploying app %s", spec.name), func() error { + // Use the app service to patch the app and remove the Flux reconciliation annotation + // This allows Flux to manage the resource again + patchOptions := app.PatchOptions{ + Namespace: r.flag.Namespace, + Name: spec.name, + Version: "", // Don't change the version during undeploy + SuspendReconciliation: false, + } + + var patchErr error + state, patchErr = r.appService.Patch(ctx, patchOptions) + return patchErr + }) + + if app.IsNotFound(err) { + return fmt.Errorf("app %s not found in namespace %s", spec.name, r.flag.Namespace) + } else if err != nil { + return err + } + + // Trigger flux reconciliation if --sync flag is set + if r.flag.Sync { + if err := r.reconcileFluxApp(ctx, spec.name, r.flag.Namespace); err != nil { + return err + } + } + + output := UndeployOutput(strings.ToLower(appResource.Kind), spec.name, r.flag.Namespace, state) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + + return nil +} + +// checkApps checks if any apps have Flux reconciliation suspended +func (r *runner) checkApps(ctx context.Context) ([]resourceInfo, error) { + // List all apps across all namespaces by passing empty namespace + apps, err := r.appService.ListApps(ctx, "") + if err != nil { + return nil, err + } + + suspendedApps := []resourceInfo{} + + for _, app := range apps.Items { + if isSuspended(&app) { + status := app.Status.Release.Status + if status == "" { + status = "Unknown" + } + suspendedApps = append(suspendedApps, resourceInfo{ + name: app.Name, + namespace: app.Namespace, + version: app.Spec.Version, + catalog: app.Spec.Catalog, + status: status, + }) + } + } + + return suspendedApps, nil +} + +// appInfo represents an app with its installation status +type appInfo struct { + name string + version string + catalog string + status string + installed bool + installedInNs string +} + +func (r *runner) listApps(ctx context.Context) error { + var installedApps *applicationv1alpha1.AppList + var catalogEntries *applicationv1alpha1.AppCatalogEntryList + + // Fetch both catalog entries and installed apps + err := RunWithSpinner("Listing apps", func() error { + // Get catalog entries + catalogDataService, serviceErr := r.getCatalogService() + if serviceErr != nil { + return serviceErr + } + + selector := fmt.Sprintf("application.giantswarm.io/catalog=%s", r.flag.Catalog) + var listErr error + catalogEntries, listErr = catalogDataService.GetEntries(ctx, selector) + if listErr != nil { + return listErr + } + + // Get installed apps in the namespace + installedApps, listErr = r.appService.ListApps(ctx, r.flag.Namespace) + if listErr != nil && !app.IsNoResources(listErr) { + return listErr + } + + return nil + }) + if err != nil { + return err + } + + if len(catalogEntries.Items) == 0 { + if _, err := fmt.Fprintf(r.stdout, "No apps found in catalog %s\n", r.flag.Catalog); err != nil { + return err + } + return nil + } + + // Group catalog entries by app name to get the latest version + latestVersions := make(map[string]applicationv1alpha1.AppCatalogEntry) + for _, entry := range catalogEntries.Items { + appName := entry.Spec.AppName + if existing, exists := latestVersions[appName]; !exists || entry.Spec.Version > existing.Spec.Version { + latestVersions[appName] = entry + } + } + + // Build a map of installed apps for quick lookup + installedMap := make(map[string]*applicationv1alpha1.App) + if installedApps != nil { + for i := range installedApps.Items { + installedApp := &installedApps.Items[i] + installedMap[installedApp.Spec.Name] = installedApp + } + } + + // Build app info list from catalog entries and mark installed ones + appInfoList := []appInfo{} + for _, entry := range latestVersions { + appName := entry.Spec.AppName + info := appInfo{ + name: appName, + version: entry.Spec.Version, + catalog: entry.Spec.Catalog.Name, + installed: false, + } + + // Check if this app is installed from the same catalog + if installedApp, exists := installedMap[appName]; exists && installedApp.Spec.Catalog == r.flag.Catalog { + info.installed = true + info.installedInNs = installedApp.Namespace + info.status = getAppStatus(installedApp) + } else { + info.status = "-" + } + + // Filter if --installed-only is set + if r.flag.InstalledOnly && !info.installed { + continue + } + + appInfoList = append(appInfoList, info) + } + + if len(appInfoList) == 0 { + if r.flag.InstalledOnly { + if _, err := fmt.Fprintf(r.stdout, "No installed apps found in namespace %s from catalog %s\n", r.flag.Namespace, r.flag.Catalog); err != nil { + return err + } + } else { + if _, err := fmt.Fprintf(r.stdout, "No apps found in catalog %s\n", r.flag.Catalog); err != nil { + return err + } + } + return nil + } + + output := ListAppsOutput(appInfoList, r.flag.Namespace, r.flag.Catalog, r.flag.InstalledOnly) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + return nil +} + +func (r *runner) listVersions(ctx context.Context, appName string) error { + var entries *applicationv1alpha1.AppCatalogEntryList + var deployedVersion string + var deployedCatalog string + err := RunWithSpinner(fmt.Sprintf("Listing versions for %s", appName), func() error { + // Get catalog data service + catalogDataService, serviceErr := r.getCatalogService() + if serviceErr != nil { + return serviceErr + } + + // List all entries for this app name + selector := fmt.Sprintf("app.kubernetes.io/name=%s", appName) + var listErr error + entries, listErr = catalogDataService.GetEntries(ctx, selector) + if listErr != nil { + return listErr + } + + // Try to get the currently deployed app version and catalog + existingApp, getErr := r.appService.GetApp(ctx, r.flag.Namespace, appName) + if getErr == nil { + deployedVersion = existingApp.Spec.Version + deployedCatalog = existingApp.Spec.Catalog + } + + return nil + }) + if err != nil { + return fmt.Errorf("failed to list versions for app %s: %w", appName, err) + } + + output := ListVersionsOutput(appName, entries, deployedVersion, deployedCatalog) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + return nil +} diff --git a/cmd/deploy/catalog.go b/cmd/deploy/catalog.go new file mode 100644 index 000000000..9d58c066c --- /dev/null +++ b/cmd/deploy/catalog.go @@ -0,0 +1,50 @@ +package deploy + +import ( + "context" + "fmt" + + applicationv1alpha1 "github.com/giantswarm/apiextensions-application/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/giantswarm/kubectl-gs/v5/pkg/data/domain/catalog" +) + +func (r *runner) listCatalogs(ctx context.Context) error { + var catalogs *applicationv1alpha1.CatalogList + + err := RunWithSpinner("Listing catalogs", func() error { + catalogs = &applicationv1alpha1.CatalogList{} + // List catalogs in both default and giantswarm namespaces + return r.ctrlClient.List(ctx, catalogs, &client.ListOptions{}) + }) + if err != nil { + return err + } + + if len(catalogs.Items) == 0 { + if _, err := fmt.Fprintf(r.stdout, "No catalogs found\n"); err != nil { + return err + } + return nil + } + + output := ListCatalogsOutput(catalogs) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + return nil +} + +func (r *runner) getCatalogService() (catalog.Interface, error) { + // Create a new catalog data service instance + catalogConfig := catalog.Config{ + Client: r.ctrlClient, + } + catalogDataService, err := catalog.New(catalogConfig) + if err != nil { + return nil, err + } + + return catalogDataService, nil +} diff --git a/cmd/deploy/command.go b/cmd/deploy/command.go new file mode 100644 index 000000000..36ccf06a5 --- /dev/null +++ b/cmd/deploy/command.go @@ -0,0 +1,155 @@ +package deploy + +import ( + "fmt" + "io" + + "github.com/giantswarm/micrologger" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + + "github.com/giantswarm/kubectl-gs/v5/pkg/commonconfig" + "github.com/giantswarm/kubectl-gs/v5/pkg/middleware" + "github.com/giantswarm/kubectl-gs/v5/pkg/middleware/renewtoken" +) + +const ( + name = "deploy" + shortDescription = "Manage GiantSwarm deployments (apps and config repositories)" + longDescription = `Manage GiantSwarm deployment of apps and config repositories to clusters. + +This command helps you deploy, undeploy, and check the status of resources +on your cluster. It supports both app deployments and config repository +deployments.` + + examples = ` # Deploy an app with a specific version + kubectl gs deploy -d my-app@1.0.0-commit + + # Deploy with synchronous reconciliation + kubectl gs deploy -d --sync my-app@1.0.0-commit + + # Deploy and undeploy on exit (Ctrl+C to undeploy) + kubectl gs deploy -d -r my-app@1.0.0-commit + + # Deploy interactively (select app and version from catalog) + kubectl gs deploy -d -i + + # Deploy interactively with a specific app name filter + kubectl gs deploy -d -i my-app + + # Deploy interactively and choose catalog first (pass empty string to catalog) + kubectl gs deploy -d -i -c "" + + # Deploy interactively with undeploy-on-exit + kubectl gs deploy -d -i -r + + # Deploy a config repository + kubectl gs deploy -t config -d config-repo@branch-ref + + # Deploy a config repository with synchronous reconciliation + kubectl gs deploy -t config -d --sync config-repo@branch-ref + + # Deploy a config repository with undeploy-on-exit + kubectl gs deploy -t config -d -r config-repo@branch-ref + + # Undeploy an app + kubectl gs deploy -u my-app + + # Undeploy an app with synchronous reconciliation + kubectl gs deploy -u --sync my-app + + # Undeploy interactively (select app from list) + kubectl gs deploy -u -i + + # Undeploy a config repository + kubectl gs deploy -t config -u config-repo + + # Undeploy a config repository with synchronous reconciliation + kubectl gs deploy -t config -u --sync config-repo + + # Show status of kustomizations, gitrepositories, and apps resources on the cluster + kubectl gs deploy --status + + # List all available apps in the catalog + kubectl gs deploy -l apps + + # List all available apps in a specific catalog + kubectl gs deploy -l apps -c my-catalog + + # List only installed apps in a specific namespace + kubectl gs deploy -l apps --installed-only -n my-namespace + + # List all versions for a specific app + kubectl gs deploy -l versions my-app + + # List all config repositories + kubectl gs deploy -l configs + + # List all config repositories in a specific namespace + kubectl gs deploy -t config -l configs -n flux-giantswarm + + # List all available catalogs + kubectl gs deploy -l catalogs + + # Deploy to a specific namespace + kubectl gs deploy -d my-app@1.0.0 -n my-namespace + + # Deploy from a specific catalog + kubectl gs deploy -d my-app@1.0.0 -c my-catalog` +) + +type Config struct { + Logger micrologger.Logger + FileSystem afero.Fs + ConfigFlags *genericclioptions.RESTClientGetter + Stderr io.Writer + Stdout io.Writer +} + +func New(config Config) (*cobra.Command, error) { + if config.Logger == nil { + return nil, fmt.Errorf("%w: %T.Logger must not be empty", ErrInvalidConfig, config) + } + if config.FileSystem == nil { + return nil, fmt.Errorf("%w: %T.FileSystem must not be empty", ErrInvalidConfig, config) + } + if config.ConfigFlags == nil { + return nil, fmt.Errorf("%w: %T.ConfigFlags must not be empty", ErrInvalidConfig, config) + } + if config.Stderr == nil { + return nil, fmt.Errorf("%w: %T.Stderr must not be empty", ErrInvalidConfig, config) + } + if config.Stdout == nil { + return nil, fmt.Errorf("%w: %T.Stdout must not be empty", ErrInvalidConfig, config) + } + + f := &flag{} + + r := &runner{ + commonConfig: &commonconfig.CommonConfig{ + ConfigFlags: config.ConfigFlags, + }, + flag: f, + logger: config.Logger, + fs: config.FileSystem, + stderr: config.Stderr, + stdout: config.Stdout, + } + + c := &cobra.Command{ + Use: name + " [resource@version]", + Short: shortDescription, + Long: longDescription, + Example: examples, + Args: cobra.MaximumNArgs(1), + RunE: r.Run, + PreRunE: middleware.Compose( + renewtoken.Middleware(*config.ConfigFlags), + ), + } + + f.Init(c) + + return c, nil +} diff --git a/cmd/deploy/config.go b/cmd/deploy/config.go new file mode 100644 index 000000000..d4e0a1cb8 --- /dev/null +++ b/cmd/deploy/config.go @@ -0,0 +1,358 @@ +package deploy + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" + k8smetadataAnnotation "github.com/giantswarm/k8smetadata/pkg/annotation" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// isSuspended checks if a resource has Flux reconciliation suspended +func isSuspended(obj metav1.Object) bool { + annotations := obj.GetAnnotations() + if annotations != nil { + if val, ok := annotations[k8smetadataAnnotation.FluxKustomizeReconcile]; ok && val == "disabled" { + return true + } + } + labels := obj.GetLabels() + if labels != nil { + if val, ok := labels[k8smetadataAnnotation.FluxKustomizeReconcile]; ok && val == "disabled" { + return true + } + } + return false +} + +func (r *runner) deployConfig(ctx context.Context, spec *resourceSpec) error { + var gitRepo *sourcev1.GitRepository + var resourceName, resourceNamespace string + + // Find and patch the GitRepository with spinner + err := RunWithSpinner(fmt.Sprintf("Deploying config repository %s@%s", spec.name, spec.version), func() error { + var findErr error + gitRepo, findErr = r.findGitRepository(ctx, spec.name, r.flag.Namespace) + if findErr != nil { + return fmt.Errorf("failed to find GitRepository for %s: %w", spec.name, findErr) + } + + // Get the resource name and namespace + resourceName = gitRepo.Name + resourceNamespace = gitRepo.Namespace + + // Create a patch to set the reconcile annotation and update the branch + patch := client.MergeFrom(gitRepo.DeepCopy()) + + // Add the Flux reconcile annotation to suspend reconciliation + annotations := gitRepo.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[k8smetadataAnnotation.FluxKustomizeReconcile] = "disabled" + gitRepo.SetAnnotations(annotations) + + // Update the spec.ref.branch to the desired version (branch) + if gitRepo.Spec.Reference == nil { + gitRepo.Spec.Reference = &sourcev1.GitRepositoryRef{} + } + gitRepo.Spec.Reference.Branch = spec.version + + // Apply the patch + patchErr := r.ctrlClient.Patch(ctx, gitRepo, patch) + if patchErr != nil { + return fmt.Errorf("failed to patch GitRepository: %w", patchErr) + } + + return nil + }) + + if err != nil { + return err + } + + // Trigger flux reconciliation if --sync flag is set + if err := r.reconcileFluxSource(ctx, resourceName, resourceNamespace); err != nil { + return err + } + + output := DeployOutput(strings.ToLower(gitRepo.Kind), resourceName, spec.version, resourceNamespace) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + + // Show reminder last if not using --undeploy-on-exit + if !r.flag.UndeployOnExit { + if _, err := fmt.Fprint(r.stdout, ReminderOutput("config", spec.name)); err != nil { + return err + } + } + + return nil +} + +func (r *runner) undeployConfig(ctx context.Context, spec *resourceSpec) error { + var resourceName, resourceNamespace string + var gitRepo *sourcev1.GitRepository + + err := RunWithSpinner(fmt.Sprintf("Undeploying config repository %s", spec.name), func() error { + // Find the GitRepository by matching its URL against the config repo name + var findErr error + gitRepo, findErr = r.findGitRepository(ctx, spec.name, r.flag.Namespace) + if findErr != nil { + return fmt.Errorf("failed to find GitRepository for %s: %w", spec.name, findErr) + } + + // Get the resource name and namespace + resourceName = gitRepo.Name + resourceNamespace = gitRepo.Namespace + + // Create a patch to remove the reconcile annotation and label + patch := client.MergeFrom(gitRepo.DeepCopy()) + + // Remove the Flux reconcile annotation + annotations := gitRepo.GetAnnotations() + if annotations != nil { + delete(annotations, k8smetadataAnnotation.FluxKustomizeReconcile) + gitRepo.SetAnnotations(annotations) + } + + // Remove the Flux reconcile label if present + labels := gitRepo.GetLabels() + if labels != nil { + delete(labels, k8smetadataAnnotation.FluxKustomizeReconcile) + gitRepo.SetLabels(labels) + } + + // Apply the patch + patchErr := r.ctrlClient.Patch(ctx, gitRepo, patch) + if patchErr != nil { + return fmt.Errorf("failed to patch GitRepository: %w", patchErr) + } + + return nil + }) + + if err != nil { + return err + } + + // Trigger flux reconciliation if --sync flag is set + if r.flag.Sync { + if err := r.reconcileFluxSource(ctx, resourceName, resourceNamespace); err != nil { + return err + } + } + + output := UndeployOutput(strings.ToLower(gitRepo.Kind), resourceName, resourceNamespace, nil) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + + return nil +} + +// findGitRepository finds a GitRepository CR by matching the config repo name in its URL +func (r *runner) findGitRepository(ctx context.Context, configRepoName, namespace string) (*sourcev1.GitRepository, error) { + // List all GitRepository resources in the namespace + gitRepoList := &sourcev1.GitRepositoryList{} + + listOptions := &client.ListOptions{ + Namespace: namespace, + } + + err := r.ctrlClient.List(ctx, gitRepoList, listOptions) + if err != nil { + return nil, fmt.Errorf("failed to list GitRepositories: %w", err) + } + + // Find the GitRepository that matches the config repo name in its URL + for i := range gitRepoList.Items { + gitRepo := &gitRepoList.Items[i] + url := gitRepo.Spec.URL + + // Check if the URL contains the config repo name + // The URL format is typically: https://github.com/giantswarm/{configRepoName} + if strings.Contains(url, fmt.Sprintf("giantswarm/%s", configRepoName)) { + return gitRepo, nil + } + } + + return nil, fmt.Errorf("%w: GitRepository for config repo %s not found in namespace %s", ErrResourceNotFound, configRepoName, namespace) +} + +// checkGitRepositories checks if any GitRepository resources have Flux reconciliation suspended +func (r *runner) checkGitRepositories(ctx context.Context) ([]resourceInfo, error) { + gitRepoList := &sourcev1.GitRepositoryList{} + + err := r.ctrlClient.List(ctx, gitRepoList) + if err != nil { + return nil, err + } + + suspendedRepos := []resourceInfo{} + + for i := range gitRepoList.Items { + gitRepo := &gitRepoList.Items[i] + if isSuspended(gitRepo) { + branch := "" + if gitRepo.Spec.Reference != nil { + branch = gitRepo.Spec.Reference.Branch + } + + // Get status from Ready condition + status := getGitRepoStatusFromConditions(gitRepo) + + suspendedRepos = append(suspendedRepos, resourceInfo{ + name: gitRepo.Name, + namespace: gitRepo.Namespace, + branch: branch, + url: gitRepo.Spec.URL, + status: status, + }) + } + } + + return suspendedRepos, nil +} + +// getGitRepoStatusFromConditions extracts status from GitRepository conditions +func getGitRepoStatusFromConditions(gitRepo *sourcev1.GitRepository) string { + for _, cond := range gitRepo.Status.Conditions { + if cond.Type == "Ready" { + if cond.Status == metav1.ConditionTrue { + return "Ready" + } + if cond.Reason != "" { + return cond.Reason + } + return "Not Ready" + } + } + return "Unknown" +} + +func (r *runner) listConfigs(ctx context.Context) error { + var gitRepoList *sourcev1.GitRepositoryList + + err := RunWithSpinner("Listing config repositories", func() error { + gitRepoList = &sourcev1.GitRepositoryList{} + + listOptions := &client.ListOptions{ + Namespace: r.flag.Namespace, + } + + return r.ctrlClient.List(ctx, gitRepoList, listOptions) + }) + + if err != nil { + return err + } + + if len(gitRepoList.Items) == 0 { + if _, err := fmt.Fprintf(r.stdout, "No config repositories found in namespace %s\n", r.flag.Namespace); err != nil { + return err + } + return nil + } + + output := ListConfigsOutput(gitRepoList, r.flag.Namespace) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + return nil +} + +// listConfigVersions lists available versions (PRs) for a config repository +func (r *runner) listConfigVersions(ctx context.Context, configRepoName string) error { + // Find the GitRepository + var gitRepo *sourcev1.GitRepository + var repoURL string + var currentBranch string + + err := RunWithSpinner(fmt.Sprintf("Finding config repository %s", configRepoName), func() error { + var findErr error + gitRepo, findErr = r.findGitRepository(ctx, configRepoName, r.flag.Namespace) + if findErr != nil { + return findErr + } + + repoURL = gitRepo.Spec.URL + if gitRepo.Spec.Reference != nil { + currentBranch = gitRepo.Spec.Reference.Branch + } + + return nil + }) + + if err != nil { + return fmt.Errorf("failed to find config repository %s: %w", configRepoName, err) + } + + // Check if gh CLI is available + if _, err := exec.LookPath("gh"); err != nil { + return fmt.Errorf("gh CLI not found in PATH. Please install gh CLI to list config versions: %w", err) + } + + // Extract GitHub repo from URL + // URL format: https://github.com/giantswarm/config-repo-name or git@github.com:giantswarm/config-repo-name.git + var githubRepo string + if strings.Contains(repoURL, "github.com") { + parts := strings.Split(repoURL, "github.com") + if len(parts) == 2 { + // Remove leading separators (/, :) and any port numbers + path := parts[1] + // Handle :port/path format + if strings.HasPrefix(path, ":") { + // Find the next / which marks the start of the repo path + if idx := strings.Index(path, "/"); idx != -1 { + path = path[idx:] + } + } + githubRepo = strings.TrimPrefix(path, "/") + githubRepo = strings.TrimPrefix(githubRepo, ":") + githubRepo = strings.TrimSuffix(githubRepo, ".git") + } + } + + if githubRepo == "" { + return fmt.Errorf("failed to extract GitHub repository from URL: %s", repoURL) + } + + // Fetch open PRs using gh CLI + var prs []PRInfo + err = RunWithSpinner(fmt.Sprintf("Fetching open PRs for %s", githubRepo), func() error { + cmd := exec.CommandContext(ctx, "gh", "pr", "list", + "--repo", githubRepo, + "--state", "open", + "--json", "number,title,headRefName,author,createdAt", + ) + + output, cmdErr := cmd.Output() + if cmdErr != nil { + return fmt.Errorf("failed to list PRs: %w", cmdErr) + } + + jsonErr := json.Unmarshal(output, &prs) + if jsonErr != nil { + return fmt.Errorf("failed to parse PR list: %w", jsonErr) + } + + return nil + }) + + if err != nil { + return err + } + + output := ListConfigVersionsOutput(configRepoName, prs, currentBranch, githubRepo) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + return nil +} diff --git a/cmd/deploy/error.go b/cmd/deploy/error.go new file mode 100644 index 000000000..ed484bed3 --- /dev/null +++ b/cmd/deploy/error.go @@ -0,0 +1,10 @@ +package deploy + +import "errors" + +var ( + ErrInvalidConfig = errors.New("invalid config") + ErrInvalidFlag = errors.New("invalid flag") + ErrInvalidArgument = errors.New("invalid argument") + ErrResourceNotFound = errors.New("resource not found") +) diff --git a/cmd/deploy/flag.go b/cmd/deploy/flag.go new file mode 100644 index 000000000..55be1286b --- /dev/null +++ b/cmd/deploy/flag.go @@ -0,0 +1,185 @@ +package deploy + +import ( + "fmt" + "slices" + "strings" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + + "github.com/giantswarm/kubectl-gs/v5/pkg/commonconfig" +) + +const ( + flagDeploy = "deploy" + flagUndeploy = "undeploy" + flagStatus = "status" + flagList = "list" + flagNamespace = "namespace" + flagType = "type" + flagCatalog = "catalog" + flagInteractive = "interactive" + flagUndeployOnExit = "undeploy-on-exit" + flagSync = "sync" + flagInstalledOnly = "installed-only" + flagName = "name" + + // Resource types + resourceTypeApp = "app" + resourceTypeConfig = "config" + + // List types + listTypeApps = "apps" + listTypeVersions = "versions" + listTypeConfigs = "configs" + listTypeCatalogs = "catalogs" + + // Default values + defaultAppNamespace = "giantswarm" + defaultConfigNamespace = "flux-giantswarm" + defaultCatalog = "control-plane-test-catalog" +) + +type flag struct { + // Action flags + Deploy bool + Undeploy bool + Status bool + List string + + // Option flags + Namespace string + Type string + Catalog string + Interactive bool + UndeployOnExit bool + Sync bool + InstalledOnly bool + Name string + + // Print flags + print *genericclioptions.PrintFlags +} + +func (f *flag) Init(cmd *cobra.Command) { + // Action flags + cmd.Flags().BoolVarP(&f.Deploy, flagDeploy, "d", false, "Deploy a resource onto a cluster") + cmd.Flags().BoolVarP(&f.Undeploy, flagUndeploy, "u", false, "Undeploy a resource from a cluster") + cmd.Flags().BoolVar(&f.Status, flagStatus, false, "Show status of all kustomizations, config repositories, and apps of the cluster") + cmd.Flags().StringVarP(&f.List, flagList, "l", "", "List resources. Valid values: apps, versions, configs, catalogs") + + // Option flags + cmd.Flags().StringVarP(&f.Type, flagType, "t", resourceTypeApp, "Resource type to handle either 'app' or 'config'") + cmd.Flags().StringVarP(&f.Catalog, flagCatalog, "c", defaultCatalog, "Catalog to use for the app deployment (only for app type)") + cmd.Flags().BoolVarP(&f.Interactive, flagInteractive, "i", false, "Interactive mode: select app and version interactively from catalog entries") + cmd.Flags().BoolVarP(&f.UndeployOnExit, flagUndeployOnExit, "r", true, "Wait for interrupt signal and undeploy on exit") + cmd.Flags().BoolVar(&f.Sync, flagSync, true, "Force synchronous deployment by triggering flux reconciliation") + cmd.Flags().BoolVar(&f.InstalledOnly, flagInstalledOnly, false, "When listing apps, show only installed apps (default: show all catalog apps with installation status)") + cmd.Flags().StringVar(&f.Name, flagName, "", "Override the app name being deployed (default: use catalog app name)") + + // Print flags for output formatting + f.print = genericclioptions.NewPrintFlags("") + f.print.AddFlags(cmd) +} + +func (f *flag) Validate(cmd *cobra.Command, cc *commonconfig.CommonConfig) error { + // Validate that exactly one action is specified + actionCount := 0 + if f.Deploy { + actionCount++ + } + if f.Undeploy { + actionCount++ + } + if f.Status { + actionCount++ + } + if f.List != "" { + actionCount++ + } + + if actionCount == 0 { + return fmt.Errorf("%w: must specify one action: -d (deploy), -u (undeploy), -s (status), or -l (list)", ErrInvalidFlag) + } + if actionCount > 1 { + return fmt.Errorf("%w: can only specify one action at a time", ErrInvalidFlag) + } + + // Validate list type if specified + if f.List != "" { + validListTypes := []string{listTypeApps, listTypeVersions, listTypeConfigs, listTypeCatalogs} + if !slices.Contains(validListTypes, f.List) { + return fmt.Errorf("%w: --%s must be one of: %s", ErrInvalidFlag, flagList, strings.Join(validListTypes, ", ")) + } + } + + // Validate resource type + validTypes := []string{resourceTypeApp, resourceTypeConfig} + if !slices.Contains(validTypes, f.Type) { + return fmt.Errorf("%w: --%s must be one of: %s", ErrInvalidFlag, flagType, strings.Join(validTypes, ", ")) + } + + // Validate interactive flag + if f.Interactive { + if !f.Deploy && !f.Undeploy { + return fmt.Errorf("%w: --%s can only be used with deploy or undeploy actions", ErrInvalidFlag, flagInteractive) + } + } + + // Validate undeploy-on-exit flag (only if explicitly set by user) + if cmd.Flags().Changed(flagUndeployOnExit) && f.UndeployOnExit { + if !f.Deploy { + return fmt.Errorf("%w: --%s can only be used with deploy action", ErrInvalidFlag, flagUndeployOnExit) + } + } + + // Validate sync flag (only if explicitly set by user) + if cmd.Flags().Changed(flagSync) && f.Sync { + if !f.Deploy && !f.Undeploy { + return fmt.Errorf("%w: --%s can only be used with deploy or undeploy actions", ErrInvalidFlag, flagSync) + } + } + + // Get namespace from global ConfigFlags + // Check if namespace flag was explicitly set by user + configFlags := cc.GetConfigFlags() + var namespace string + var namespaceExplicitlySet bool + + if cf, ok := configFlags.(*genericclioptions.ConfigFlags); ok && cf.Namespace != nil && *cf.Namespace != "" { + // User explicitly set the namespace via -n flag + namespace = *cf.Namespace + namespaceExplicitlySet = true + } + + // Set default namespace based on resource type or list type if not explicitly set + if !namespaceExplicitlySet { + // If listing configs, use config namespace + if f.List == listTypeConfigs { + namespace = defaultConfigNamespace + } else if f.Type == resourceTypeApp { + namespace = defaultAppNamespace + } else if f.Type == resourceTypeConfig { + namespace = defaultConfigNamespace + } + } + f.Namespace = namespace + + return nil +} + +func (f *flag) GetAction() string { + switch { + case f.Deploy: + return "deploy" + case f.Undeploy: + return "undeploy" + case f.Status: + return "status" + case f.List != "": + return "list" + default: + return "" + } +} diff --git a/cmd/deploy/restore.go b/cmd/deploy/restore.go new file mode 100644 index 000000000..ad354e671 --- /dev/null +++ b/cmd/deploy/restore.go @@ -0,0 +1,195 @@ +package deploy + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" + k8smetadataAnnotation "github.com/giantswarm/k8smetadata/pkg/annotation" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/giantswarm/kubectl-gs/v5/pkg/data/domain/app" +) + +// savedAppState holds the previous state of an app before deployment +type savedAppState struct { + name string + namespace string + version string + catalog string +} + +// savedConfigState holds the previous state of a config repository before deployment +type savedConfigState struct { + resourceName string + resourceNamespace string + branch string +} + +// captureAppState captures the current state of an app before deployment +func (r *runner) captureAppState(ctx context.Context, name, namespace string) (*savedAppState, error) { + app, err := r.appService.GetApp(ctx, namespace, name) + if err != nil { + // Return error (including NotFound) - caller will handle appropriately + return nil, err + } + + return &savedAppState{ + name: name, + namespace: namespace, + version: app.Spec.Version, + catalog: app.Spec.Catalog, + }, nil +} + +// captureConfigState captures the current state of a config repository before deployment +func (r *runner) captureConfigState(ctx context.Context, configName, namespace string) (*savedConfigState, error) { + result, err := r.findGitRepository(ctx, configName, namespace) + if err != nil { + // Return error (including NotFound) - caller will handle appropriately + return nil, err + } + + var branch string + if result.Spec.Reference != nil { + branch = result.Spec.Reference.Branch + } + + return &savedConfigState{ + resourceName: result.Name, + resourceNamespace: result.Namespace, + branch: branch, + }, nil +} + +// restoreState restores the previous state based on resource type +func (r *runner) restoreState(ctx context.Context, resourceType string, savedState interface{}) error { + switch resourceType { + case "app": + return r.restoreAppState(ctx, savedState.(*savedAppState)) + case "config": + return r.restoreConfigState(ctx, savedState.(*savedConfigState)) + default: + return fmt.Errorf("unsupported resource type: %s", resourceType) + } +} + +// waitForInterruptAndRestore waits for an interrupt signal and restores the previous state +func (r *runner) waitForInterruptAndRestore(ctx context.Context, resourceType string, savedState interface{}) error { + // Set up signal handler + signalCtx, _ := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + + //nolint:errcheck // informational output + fmt.Fprintf(r.stdout, "%s Waiting for interrupt signal (Ctrl+C) to restore previous state...\n", + infoStyle.Render("ℹ")) + //nolint:errcheck // informational output + fmt.Fprintf(r.stdout, "%s Press Ctrl+C to restore and exit\n", + mutedStyle.Render("⌨")) + + // Wait for either signal or context cancellation + <-signalCtx.Done() + //nolint:errcheck // informational output + fmt.Fprintf(r.stdout, "\n%s Stopping and restoring previous state...\n", warningStyle.Render("⚠")) + + return r.restoreState(ctx, resourceType, savedState) +} + +// restoreAppState restores an app to its previous state +func (r *runner) restoreAppState(ctx context.Context, state *savedAppState) error { + if state == nil { + // App didn't exist before, so delete it + //nolint:errcheck // informational warning message + fmt.Fprintf(r.stderr, "App did not exist before deployment, deletion not yet implemented\n") + return nil + } + + // Restore the app to its previous version and suspension state + // The app service Patch method handles setting/unsetting the flux reconciliation annotation + // based on the SuspendReconciliation parameter + err := RunWithSpinner(fmt.Sprintf("Restoring app %s to version %s", state.name, state.version), func() error { + patchOptions := app.PatchOptions{ + Namespace: state.namespace, + Name: state.name, + Version: state.version, + SuspendReconciliation: false, // Always unsuspend during restore + } + _, patchErr := r.appService.Patch(ctx, patchOptions) + return patchErr + }) + if err != nil { + return fmt.Errorf("failed to restore app state: %w", err) + } + + output := fmt.Sprintf("%s App %s restored to version %s\n", + successStyle.Render("✓"), + state.name, + state.version) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + + return nil +} + +// restoreConfigState restores a config repository to its previous state +func (r *runner) restoreConfigState(ctx context.Context, state *savedConfigState) error { + if state == nil { + // Config didn't exist before + //nolint:errcheck // informational warning message + fmt.Fprintf(r.stderr, "Config did not exist before deployment, deletion not yet implemented\n") + return nil + } + + var gitRepo *sourcev1.GitRepository + + // Restore the config to its previous branch + err := RunWithSpinner(fmt.Sprintf("Restoring config %s to branch %s", state.resourceName, state.branch), func() error { + // Get the GitRepository directly by name and namespace + gitRepo = &sourcev1.GitRepository{} + key := client.ObjectKey{ + Name: state.resourceName, + Namespace: state.resourceNamespace, + } + + getErr := r.ctrlClient.Get(ctx, key, gitRepo) + if getErr != nil { + return fmt.Errorf("failed to get GitRepository: %w", getErr) + } + + // Create a patch to update the branch + patch := client.MergeFrom(gitRepo.DeepCopy()) + + // Update the branch + if gitRepo.Spec.Reference == nil { + gitRepo.Spec.Reference = &sourcev1.GitRepositoryRef{} + } + gitRepo.Spec.Reference.Branch = state.branch + + // Restore to non-suspended state - ensure annotation and label are removed + annotations := gitRepo.GetAnnotations() + labels := gitRepo.GetLabels() + delete(annotations, k8smetadataAnnotation.FluxKustomizeReconcile) + delete(labels, k8smetadataAnnotation.FluxKustomizeReconcile) + gitRepo.SetAnnotations(annotations) + gitRepo.SetLabels(labels) + + // Apply the patch + return r.ctrlClient.Patch(ctx, gitRepo, patch) + }) + if err != nil { + return fmt.Errorf("failed to restore config state: %w", err) + } + + output := fmt.Sprintf("%s Config %s restored to branch %s\n", + successStyle.Render("✓"), + state.resourceName, + state.branch) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + + return nil +} diff --git a/cmd/deploy/runner.go b/cmd/deploy/runner.go new file mode 100644 index 000000000..909ae4c19 --- /dev/null +++ b/cmd/deploy/runner.go @@ -0,0 +1,500 @@ +package deploy + +import ( + "context" + "fmt" + "io" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/giantswarm/micrologger" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/giantswarm/kubectl-gs/v5/pkg/commonconfig" + "github.com/giantswarm/kubectl-gs/v5/pkg/data/domain/app" +) + +type runner struct { + commonConfig *commonconfig.CommonConfig + flag *flag + logger micrologger.Logger + fs afero.Fs + + // Service dependencies + appService app.Interface + ctrlClient client.Client + + stderr io.Writer + stdout io.Writer +} + +type resourceSpec struct { + name string + version string +} + +type resourceInfo struct { + name string + namespace string + reason string + version string + catalog string + branch string + url string + status string +} + +func (r *runner) Run(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + if err := r.flag.Validate(cmd, r.commonConfig); err != nil { + return err + } + + // Initialize dependencies + k8sClient, err := r.commonConfig.GetClient(r.logger) + if err != nil { + return err + } + + r.ctrlClient = k8sClient.CtrlClient() + + serviceConfig := app.Config{ + Client: r.ctrlClient, + } + r.appService, err = app.New(serviceConfig) + if err != nil { + return err + } + + return r.run(ctx, cmd, args) +} + +func (r *runner) run(ctx context.Context, cmd *cobra.Command, args []string) error { + action := r.flag.GetAction() + + switch action { + case "deploy": + return r.handleDeploy(ctx, cmd, args) + case "undeploy": + return r.handleUndeploy(ctx, args) + case "status": + return r.handleStatus(ctx) + case "list": + return r.handleList(ctx, args) + default: + return fmt.Errorf("%w: unknown action: %s", ErrInvalidFlag, action) + } +} + +func (r *runner) handleDeploy(ctx context.Context, cmd *cobra.Command, args []string) error { + var spec *resourceSpec + var err error + + // Handle interactive mode + if r.flag.Interactive { + // Interactive mode: select app and version from catalog entries + spec, err = r.handleInteractiveMode(ctx, cmd, args) + if err != nil { + return err + } + } else { + // Non-interactive mode: parse from args + if len(args) == 0 { + return fmt.Errorf("%w: resource@version argument is required for deploy action", ErrInvalidArgument) + } + + spec, err = r.parseResourceSpec(args[0], true) + if err != nil { + return err + } + } + + // Determine the resource name (can be overridden by --name flag for apps) + resourceName := spec.name + if r.flag.Type == "app" && r.flag.Name != "" { + resourceName = r.flag.Name + } + + // Capture state before deployment if undeploy-on-exit is enabled + var savedState interface{} + var stateCaptured bool + if r.flag.UndeployOnExit { + var captureErr error + switch r.flag.Type { + case "app": + savedState, captureErr = r.captureAppState(ctx, resourceName, r.flag.Namespace) + case "config": + savedState, captureErr = r.captureConfigState(ctx, spec.name, r.flag.Namespace) + default: + return fmt.Errorf("%w: unsupported resource type: %s", ErrInvalidFlag, r.flag.Type) + } + if captureErr != nil { + //nolint:errcheck // non-critical warning message + fmt.Fprintf(r.stderr, "Warning: failed to capture state for restore: %v\n", captureErr) + } else { + stateCaptured = true + } + } + + // Perform the deployment + var deployErr error + var deploymentSucceeded bool + switch r.flag.Type { + case "app": + deployErr = r.deployApp(ctx, spec) + case "config": + deployErr = r.deployConfig(ctx, spec) + default: + return fmt.Errorf("%w: unsupported resource type: %s", ErrInvalidFlag, r.flag.Type) + } + + if deployErr != nil { + // If undeploy-on-exit is enabled and state was captured, restore before returning error + if r.flag.UndeployOnExit && stateCaptured { + //nolint:errcheck // informational message + fmt.Fprintf(r.stderr, "\n%s Deployment failed, restoring previous state...\n", warningStyle.Render("⚠")) + restoreErr := r.restoreState(ctx, r.flag.Type, savedState) + if restoreErr != nil { + //nolint:errcheck // error message already being handled + fmt.Fprintf(r.stderr, "Error: failed to restore state: %v\n", restoreErr) + } + } + return deployErr + } + + deploymentSucceeded = true + + // If undeploy-on-exit is enabled, wait for interrupt and restore + if r.flag.UndeployOnExit && deploymentSucceeded { + return r.waitForInterruptAndRestore(ctx, r.flag.Type, savedState) + } + + return nil +} + +func (r *runner) handleInteractiveMode(ctx context.Context, cmd *cobra.Command, args []string) (*resourceSpec, error) { + if r.flag.Type == resourceTypeConfig { + return r.handleInteractiveConfigMode(ctx, args) + } + + // Extract app name filter from args if provided + appNameFilter := "" + if len(args) > 0 { + // Parse args[0] to extract app name (before @) + parts := strings.Split(args[0], "@") + appNameFilter = parts[0] + } + + // Check if catalog flag was explicitly set to empty string (to trigger catalog selection) + catalogFilter := r.flag.Catalog + catalogChanged := cmd.Flags().Changed("catalog") + + // If catalog was explicitly set to empty string, let user select it + if catalogChanged && catalogFilter == "" { + selectedCatalog, err := r.selectCatalog(ctx) + if err != nil { + return nil, fmt.Errorf("failed to select catalog: %w", err) + } + catalogFilter = selectedCatalog + // Update the flag so deployment uses the selected catalog + r.flag.Catalog = selectedCatalog + } + + // Select app catalog entry + result, err := r.selectCatalogEntry(ctx, appNameFilter, catalogFilter) + if err != nil { + return nil, fmt.Errorf("failed to select catalog entry: %w", err) + } + + if result.Canceled { + return nil, fmt.Errorf("selection canceled") + } + + // Update catalog flag to match the selected entry's catalog + r.flag.Catalog = result.Catalog + + return &resourceSpec{ + name: result.AppName, + version: result.Version, + }, nil +} + +func (r *runner) handleInteractiveConfigMode(ctx context.Context, args []string) (*resourceSpec, error) { + // Extract config name filter from args if provided + configNameFilter := "" + if len(args) > 0 { + // Parse args[0] to extract config name (before @) + parts := strings.Split(args[0], "@") + configNameFilter = parts[0] + } + + // Select config version (combined repo + branch/PR) + result, err := r.selectConfigVersion(ctx, configNameFilter) + if err != nil { + return nil, fmt.Errorf("failed to select config version: %w", err) + } + + if result.Canceled { + return nil, fmt.Errorf("selection canceled") + } + + return &resourceSpec{ + name: result.ConfigRepo, + version: result.Branch, + }, nil +} + +func (r *runner) handleUndeploy(ctx context.Context, args []string) error { + var spec *resourceSpec + var err error + + // Handle interactive mode + if r.flag.Interactive { + if r.flag.Type == resourceTypeConfig { + spec, err = r.handleInteractiveUndeployConfig(ctx) + } else { + spec, err = r.handleInteractiveUndeploy(ctx) + } + if err != nil { + return err + } + } else { + // Non-interactive mode: parse from args + if len(args) == 0 { + return fmt.Errorf("%w: resource name is required for undeploy action", ErrInvalidArgument) + } + + spec, err = r.parseResourceSpec(args[0], false) + if err != nil { + return err + } + } + + switch r.flag.Type { + case "app": + return r.undeployApp(ctx, spec) + case "config": + return r.undeployConfig(ctx, spec) + } + + return fmt.Errorf("%w: unsupported resource type: %s", ErrInvalidFlag, r.flag.Type) +} + +func (r *runner) handleInteractiveUndeploy(ctx context.Context) (*resourceSpec, error) { + // Get list of installed apps in the namespace + installedApps, err := r.appService.ListApps(ctx, r.flag.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to list installed apps: %w", err) + } + + if len(installedApps.Items) == 0 { + return nil, fmt.Errorf("no apps found in namespace %s", r.flag.Namespace) + } + + // Build a list of app items for selection + items := make([]interface{}, len(installedApps.Items)) + for i, app := range installedApps.Items { + // Format: name version catalog + title := fmt.Sprintf("%-40s %-20s %s", + app.Spec.Name, + app.Spec.Version, + app.Spec.Catalog) + + items[i] = catalogEntryItem{ + appName: app.Spec.Name, + version: app.Spec.Version, + catalog: app.Spec.Catalog, + title: title, + } + } + + // Create and run the selector + model := newSelectorModel(items, "Select app to undeploy:") + + p := tea.NewProgram(model, tea.WithAltScreen()) + finalModel, err := p.Run() + if err != nil { + return nil, fmt.Errorf("error running app selector: %w", err) + } + + m := finalModel.(selectorModel) + if m.err != nil { + return nil, m.err + } + + if m.selected == nil { + return nil, fmt.Errorf("selection canceled") + } + + selectedItem, ok := m.selected.(catalogEntryItem) + if !ok { + return nil, fmt.Errorf("unexpected item type") + } + + return &resourceSpec{ + name: selectedItem.appName, + }, nil +} + +func (r *runner) handleInteractiveUndeployConfig(ctx context.Context) (*resourceSpec, error) { + // Get list of config repositories in the namespace + gitRepoList, err := r.listAllConfigRepos(ctx, r.flag.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to list config repositories: %w", err) + } + + if len(gitRepoList.Items) == 0 { + return nil, fmt.Errorf("no config repositories found in namespace %s", r.flag.Namespace) + } + + // Build a list of config items for selection, filtered to those with suspended reconciliation + items := make([]interface{}, 0) + for _, gitRepo := range gitRepoList.Items { + // Only show configs with suspended reconciliation (deployed by us) + if !isSuspended(&gitRepo) { + continue + } + + configName := extractConfigNameFromURL(gitRepo.Spec.URL) + if configName == "" { + continue + } + + currentBranch := "" + if gitRepo.Spec.Reference != nil { + currentBranch = gitRepo.Spec.Reference.Branch + } + + items = append(items, configRepoItem{ + name: configName, + url: gitRepo.Spec.URL, + branch: currentBranch, + }) + } + + if len(items) == 0 { + return nil, fmt.Errorf("no deployed config repositories found in namespace %s", r.flag.Namespace) + } + + // Create and run the selector + model := newSelectorModel(items, "Select config to undeploy:") + + p := tea.NewProgram(model, tea.WithAltScreen()) + finalModel, err := p.Run() + if err != nil { + return nil, fmt.Errorf("error running config selector: %w", err) + } + + m := finalModel.(selectorModel) + if m.err != nil { + return nil, m.err + } + + if m.selected == nil { + return nil, fmt.Errorf("selection canceled") + } + + selectedItem, ok := m.selected.(configRepoItem) + if !ok { + return nil, fmt.Errorf("unexpected item type") + } + + return &resourceSpec{ + name: selectedItem.name, + }, nil +} + +func (r *runner) handleStatus(ctx context.Context) error { + var kustomizationsReady bool + var notReadyKustomizations, suspendedKustomizations, suspendedApps, suspendedGitRepos []resourceInfo + + // Check kustomizations with spinner + if err := RunWithSpinner("Checking kustomizations", func() error { + var err error + kustomizationsReady, notReadyKustomizations, suspendedKustomizations, err = r.checkKustomizations(ctx) + return err + }); err != nil { + //nolint:errcheck // non-critical error message + fmt.Fprintf(r.stderr, "Error checking kustomizations: %v\n", err) + } + + // Check apps with spinner + if err := RunWithSpinner("Checking apps", func() error { + var err error + suspendedApps, err = r.checkApps(ctx) + return err + }); err != nil { + //nolint:errcheck // non-critical error message + fmt.Fprintf(r.stderr, "Error checking apps: %v\n", err) + } + + // Check config repositories with spinner + if err := RunWithSpinner("Checking git repositories", func() error { + var err error + suspendedGitRepos, err = r.checkGitRepositories(ctx) + return err + }); err != nil { + //nolint:errcheck // non-critical error message + fmt.Fprintf(r.stderr, "Error checking git repositories: %v\n", err) + } + + // Display formatted status + output := StatusOutput(kustomizationsReady, notReadyKustomizations, suspendedKustomizations, suspendedApps, suspendedGitRepos) + if _, err := fmt.Fprint(r.stdout, output); err != nil { + return err + } + + return nil +} + +func (r *runner) handleList(ctx context.Context, args []string) error { + switch r.flag.List { + case "apps": + return r.listApps(ctx) + case "versions": + if len(args) == 0 { + return fmt.Errorf("%w: resource name is required for listing versions", ErrInvalidArgument) + } + if r.flag.Type == resourceTypeConfig { + return r.listConfigVersions(ctx, args[0]) + } + return r.listVersions(ctx, args[0]) + case "configs": + return r.listConfigs(ctx) + case "catalogs": + return r.listCatalogs(ctx) + default: + return fmt.Errorf("%w: unknown list type: %s", ErrInvalidFlag, r.flag.List) + } +} + +func (r *runner) parseResourceSpec(arg string, requireVersion bool) (*resourceSpec, error) { + parts := strings.Split(arg, "@") + + if len(parts) == 1 { + if requireVersion { + return nil, fmt.Errorf("%w: version is required, format: resource@version", ErrInvalidArgument) + } + return &resourceSpec{ + name: parts[0], + }, nil + } + + if len(parts) == 2 { + if parts[0] == "" { + return nil, fmt.Errorf("%w: resource name cannot be empty", ErrInvalidArgument) + } + if parts[1] == "" && requireVersion { + return nil, fmt.Errorf("%w: version cannot be empty", ErrInvalidArgument) + } + return &resourceSpec{ + name: parts[0], + version: parts[1], + }, nil + } + + return nil, fmt.Errorf("%w: invalid resource format, expected: resource@version", ErrInvalidArgument) +} diff --git a/cmd/deploy/select.go b/cmd/deploy/select.go new file mode 100644 index 000000000..a664b39f5 --- /dev/null +++ b/cmd/deploy/select.go @@ -0,0 +1,619 @@ +package deploy + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "slices" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + applicationv1alpha1 "github.com/giantswarm/apiextensions-application/api/v1alpha1" + "github.com/sahilm/fuzzy" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + selectedItemStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")) + normalItemStyle = lipgloss.NewStyle() + promptStyleSelect = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + matchStyleSelect = lipgloss.NewStyle().Foreground(lipgloss.Color("211")).Underline(true) + selectedMatchStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("211")).Underline(true) +) + +// SelectionResult holds the result of the interactive selection +type SelectionResult struct { + AppName string + Version string + Catalog string + ConfigRepo string + Branch string + Canceled bool +} + +// catalogItem represents an item in the catalog selector +type catalogItem struct { + name string +} + +func (i catalogItem) String() string { + return i.name +} + +// catalogEntryItem represents an item in the catalog entry selector +type catalogEntryItem struct { + appName string + version string + catalog string + namespace string + date string + title string // Combined string for display and fuzzy matching +} + +func (i catalogEntryItem) String() string { + return i.title +} + +// configRepoItem represents a config repository in the selector +type configRepoItem struct { + name string + url string + branch string +} + +func (i configRepoItem) String() string { + if i.branch != "" { + return fmt.Sprintf("%-40s (current branch: %s)", i.name, i.branch) + } + return i.name +} + +// configPRItem represents a PR for a config repository +type configPRItem struct { + displayText string +} + +func (i configPRItem) String() string { + return i.displayText +} + +// configVersionItem represents a combined config repo + branch/PR for selection +type configVersionItem struct { + configName string + branch string + prNumber int + prTitle string + author string + isDeployed bool + displayText string +} + +func (i configVersionItem) String() string { + return i.displayText +} + +type itemMatch struct { + item interface{} + positions []int +} + +// selectorModel is the bubbletea model for the interactive selector +type selectorModel struct { + items []interface{} + filtered []itemMatch + cursor int + filter textinput.Model + width int + height int + prompt string + selected interface{} + err error +} + +func newSelectorModel(items []interface{}, prompt string) selectorModel { + ti := textinput.New() + ti.Placeholder = "" + ti.Focus() + ti.CharLimit = 156 + ti.Width = 50 + + // Initialize filtered list + filtered := make([]itemMatch, len(items)) + for i, item := range items { + filtered[i] = itemMatch{item: item, positions: nil} + } + + return selectorModel{ + items: items, + filtered: filtered, + cursor: 0, + filter: ti, + prompt: prompt, + } +} + +func (m selectorModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m *selectorModel) filterItems() { + query := m.filter.Value() + if query == "" { + m.filtered = make([]itemMatch, len(m.items)) + for i, item := range m.items { + m.filtered[i] = itemMatch{item: item, positions: nil} + } + } else { + // Use fuzzy matching library + matches := fuzzy.FindFromNoSort(query, itemSource(m.items)) + m.filtered = make([]itemMatch, len(matches)) + for i, match := range matches { + m.filtered[i] = itemMatch{ + item: m.items[match.Index], + positions: match.MatchedIndexes, + } + } + } + // Reset cursor if out of bounds + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + } + if m.cursor < 0 && len(m.filtered) > 0 { + m.cursor = 0 + } +} + +// itemSource implements fuzzy.Source interface +type itemSource []interface{} + +func (s itemSource) String(i int) string { + switch item := s[i].(type) { + case catalogItem: + return item.String() + case catalogEntryItem: + return item.String() + case configRepoItem: + return item.String() + case configPRItem: + return item.String() + case configVersionItem: + return item.String() + default: + return "" + } +} + +func (s itemSource) Len() int { + return len(s) +} + +func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "enter": + if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { + m.selected = m.filtered[m.cursor].item + } + return m, tea.Quit + case "up", "ctrl+k": + if m.cursor < len(m.filtered)-1 { + m.cursor++ + } + return m, nil + case "down", "ctrl+j": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + default: + // Update the filter input + m.filter, cmd = m.filter.Update(msg) + m.filterItems() + return m, cmd + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + } + + return m, nil +} + +// highlightMatches highlights the matching characters at given positions +func highlightMatches(text string, positions []int, isSelected bool) string { + if len(positions) == 0 { + if isSelected { + return selectedItemStyle.Render(text) + } + return normalItemStyle.Render(text) + } + + // Create a set of matched positions for quick lookup + matchedPos := make(map[int]bool) + for _, pos := range positions { + matchedPos[pos] = true + } + + var result strings.Builder + textRunes := []rune(text) + + for i, char := range textRunes { + if matchedPos[i] { + if isSelected { + result.WriteString(selectedMatchStyle.Render(string(char))) + } else { + result.WriteString(matchStyleSelect.Render(string(char))) + } + } else { + if isSelected { + result.WriteString(selectedItemStyle.Render(string(char))) + } else { + result.WriteString(normalItemStyle.Render(string(char))) + } + } + } + + return result.String() +} + +func (m selectorModel) View() string { + var s strings.Builder + + // Calculate how many items we can show + availableLines := m.height - 2 // Reserve 2 lines for prompt + + // Determine which items to show (centered around cursor) + start := 0 + end := len(m.filtered) + + if end > availableLines { + // Center the view around the cursor + start = m.cursor - availableLines/2 + if start < 0 { + start = 0 + } + end = start + availableLines + if end > len(m.filtered) { + end = len(m.filtered) + start = end - availableLines + if start < 0 { + start = 0 + } + } + } + + // Add empty lines at the top to push items to bottom + itemCount := end - start + for i := itemCount; i < availableLines; i++ { + s.WriteString("\n") + } + + // Render items in reverse order (bottom-up, with first item closest to prompt) + for i := end - 1; i >= start; i-- { + var displayText string + switch item := m.filtered[i].item.(type) { + case catalogItem: + displayText = item.String() + case catalogEntryItem: + displayText = item.String() + case configRepoItem: + displayText = item.String() + case configPRItem: + displayText = item.String() + case configVersionItem: + displayText = item.String() + } + s.WriteString(highlightMatches(displayText, m.filtered[i].positions, i == m.cursor)) + s.WriteString("\n") + } + + // Render prompt at the bottom + s.WriteString(promptStyleSelect.Render(m.prompt)) + s.WriteString(m.filter.View()) + + return s.String() +} + +// selectCatalog presents a catalog selector to the user and returns the selected catalog name +func (r *runner) selectCatalog(ctx context.Context) (string, error) { + // Get all catalogs + catalogs, err := r.listAllCatalogs(ctx) + if err != nil { + return "", fmt.Errorf("failed to list catalogs: %w", err) + } + + if len(catalogs.Items) == 0 { + return "", fmt.Errorf("no catalogs found") + } + + // Convert to catalog items + items := make([]interface{}, len(catalogs.Items)) + for i, catalog := range catalogs.Items { + items[i] = catalogItem{name: catalog.Name} + } + + // Create and run the selector + model := newSelectorModel(items, "Select catalog ") + p := tea.NewProgram(model, tea.WithAltScreen()) + + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("error running catalog selector: %w", err) + } + + // Check if selection was made + m := finalModel.(selectorModel) + if m.selected == nil { + return "", fmt.Errorf("no catalog selected") + } + + catalogItem, ok := m.selected.(catalogItem) + if !ok { + return "", fmt.Errorf("invalid selection") + } + + return catalogItem.name, nil +} + +// selectCatalogEntry presents a catalog entry selector to the user +func (r *runner) selectCatalogEntry(ctx context.Context, appNameFilter string, catalogFilter string) (*SelectionResult, error) { + // Get catalog data service + catalogDataService, err := r.getCatalogService() + if err != nil { + return nil, fmt.Errorf("failed to create catalog service: %w", err) + } + + // Build selector for listing catalog entries + var selectors []string + if appNameFilter != "" { + selectors = append(selectors, fmt.Sprintf("app.kubernetes.io/name=%s", appNameFilter)) + } + if catalogFilter != "" { + selectors = append(selectors, fmt.Sprintf("application.giantswarm.io/catalog=%s", catalogFilter)) + } + + selector := strings.Join(selectors, ",") + + // List catalog entries + entries, err := catalogDataService.GetEntries(ctx, selector) + if err != nil { + return nil, fmt.Errorf("failed to list catalog entries: %w", err) + } + + if len(entries.Items) == 0 { + return nil, fmt.Errorf("no catalog entries found") + } + + // Sort entries by date (newest first in array, so after reverse rendering newest appears at bottom) + sortedEntries := make([]applicationv1alpha1.AppCatalogEntry, len(entries.Items)) + copy(sortedEntries, entries.Items) + slices.SortFunc(sortedEntries, func(a, b applicationv1alpha1.AppCatalogEntry) int { + // Sort by date descending (newest first) + if b.Spec.DateUpdated.Before(a.Spec.DateUpdated) { + return -1 + } + if a.Spec.DateUpdated.Before(b.Spec.DateUpdated) { + return 1 + } + return 0 + }) + + // Convert to catalog entry items + items := make([]interface{}, len(sortedEntries)) + for i, entry := range sortedEntries { + // Format: name version date [catalog] + title := fmt.Sprintf("%-40s %-20s %s", + entry.Spec.AppName, + entry.Spec.Version, + entry.Spec.DateUpdated.Format("2006-01-02 15:04:05")) + + items[i] = catalogEntryItem{ + appName: entry.Spec.AppName, + version: entry.Spec.Version, + catalog: entry.Spec.Catalog.Name, + namespace: entry.Namespace, + date: entry.Spec.DateUpdated.Format("2006-01-02 15:04:05"), + title: title, + } + } + + // Create and run thse selector + model := newSelectorModel(items, "Select app and version ") + p := tea.NewProgram(model, tea.WithAltScreen()) + + finalModel, err := p.Run() + if err != nil { + return nil, fmt.Errorf("error running catalog entry selector: %w", err) + } + + // Check if selection was made + m := finalModel.(selectorModel) + if m.selected == nil { + return &SelectionResult{Canceled: true}, nil + } + + entryItem, ok := m.selected.(catalogEntryItem) + if !ok { + return nil, fmt.Errorf("invalid selection") + } + + return &SelectionResult{ + AppName: entryItem.appName, + Version: entryItem.version, + Catalog: entryItem.catalog, + Canceled: false, + }, nil +} + +// listAllCatalogs gets all catalogs in the cluster +func (r *runner) listAllCatalogs(ctx context.Context) (*applicationv1alpha1.CatalogList, error) { + catalogs := &applicationv1alpha1.CatalogList{} + err := r.ctrlClient.List(ctx, catalogs) + return catalogs, err +} + +// listAllConfigRepos lists all GitRepository resources in the namespace +func (r *runner) listAllConfigRepos(ctx context.Context, namespace string) (*sourcev1.GitRepositoryList, error) { + gitRepoList := &sourcev1.GitRepositoryList{} + listOptions := &client.ListOptions{ + Namespace: namespace, + } + err := r.ctrlClient.List(ctx, gitRepoList, listOptions) + return gitRepoList, err +} + +// extractConfigNameFromURL extracts the config repository name from its GitHub URL +func extractConfigNameFromURL(url string) string { + if !strings.Contains(url, "giantswarm/") { + return "" + } + parts := strings.Split(url, "giantswarm/") + if len(parts) < 2 { + return "" + } + name := parts[len(parts)-1] + name = strings.TrimSuffix(name, ".git") + return name +} + +// selectConfigVersion presents a combined selector of all config repos and their PRs +func (r *runner) selectConfigVersion(ctx context.Context, configNameFilter string) (*SelectionResult, error) { + // Get all config repositories + gitRepoList, err := r.listAllConfigRepos(ctx, r.flag.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to list config repositories: %w", err) + } + + if len(gitRepoList.Items) == 0 { + return nil, fmt.Errorf("no config repositories found in namespace %s", r.flag.Namespace) + } + + // Collect all versions (PRs) from all matching repos + var items []interface{} + + err = RunWithSpinner("Fetching PRs from config repositories", func() error { + for _, gitRepo := range gitRepoList.Items { + configName := extractConfigNameFromURL(gitRepo.Spec.URL) + if configName == "" { + continue + } + + // Filter by name if provided + if configNameFilter != "" && !strings.Contains(configName, configNameFilter) { + continue + } + + currentBranch := "" + if gitRepo.Spec.Reference != nil { + currentBranch = gitRepo.Spec.Reference.Branch + } + + // Extract GitHub repo from URL + repoURL := gitRepo.Spec.URL + parts := strings.Split(repoURL, "github.com") + if len(parts) != 2 { + continue + } + + path := parts[1] + if strings.HasPrefix(path, ":") { + if idx := strings.Index(path, "/"); idx != -1 { + path = path[idx:] + } + } + githubRepo := strings.TrimPrefix(path, "/") + githubRepo = strings.TrimPrefix(githubRepo, ":") + githubRepo = strings.TrimSuffix(githubRepo, ".git") + + // Fetch PRs for this repo + var prs []PRInfo + cmd := exec.CommandContext(ctx, "gh", "pr", "list", + "--repo", githubRepo, + "--state", "open", + "--json", "number,title,headRefName,author,createdAt", + ) + + output, cmdErr := cmd.Output() + if cmdErr != nil { + // Skip repos where we can't fetch PRs + continue + } + + jsonErr := json.Unmarshal(output, &prs) + if jsonErr != nil { + continue + } + + // Add each PR as an item + for _, pr := range prs { + isDeployed := currentBranch != "" && pr.HeadRefName == currentBranch + + // Format: config-name branch [PR #XXX: title by @author] + displayText := fmt.Sprintf("%-30s %-40s", + configName, + pr.HeadRefName) + + if isDeployed { + displayText += " (deployed)" + } + + items = append(items, configVersionItem{ + configName: configName, + branch: pr.HeadRefName, + prNumber: pr.Number, + prTitle: pr.Title, + author: pr.Author.Login, + isDeployed: isDeployed, + displayText: displayText, + }) + } + } + return nil + }) + if err != nil { + return nil, err + } + + if len(items) == 0 { + if configNameFilter != "" { + return nil, fmt.Errorf("no open PRs found for config repositories matching '%s'", configNameFilter) + } + return nil, fmt.Errorf("no open PRs found for any config repository") + } + + // Create and run the selector + model := newSelectorModel(items, "Select config and branch ") + p := tea.NewProgram(model, tea.WithAltScreen()) + + finalModel, err := p.Run() + if err != nil { + return nil, fmt.Errorf("error running config version selector: %w", err) + } + + // Check if selection was made + m := finalModel.(selectorModel) + if m.selected == nil { + return &SelectionResult{Canceled: true}, nil + } + + versionItem, ok := m.selected.(configVersionItem) + if !ok { + return nil, fmt.Errorf("invalid selection") + } + + return &SelectionResult{ + ConfigRepo: versionItem.configName, + Branch: versionItem.branch, + Canceled: false, + }, nil +} diff --git a/cmd/deploy/select_test.go b/cmd/deploy/select_test.go new file mode 100644 index 000000000..92c08738c --- /dev/null +++ b/cmd/deploy/select_test.go @@ -0,0 +1,178 @@ +package deploy + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestSelectorModel_FilterItems(t *testing.T) { + tests := []struct { + name string + items []interface{} + filterValue string + expectedCount int + }{ + { + name: "no filter returns all items", + items: []interface{}{ + catalogItem{name: "control-plane-catalog"}, + catalogItem{name: "giantswarm-catalog"}, + catalogItem{name: "test-catalog"}, + }, + filterValue: "", + expectedCount: 3, + }, + { + name: "filter matches partial string", + items: []interface{}{ + catalogItem{name: "control-plane-catalog"}, + catalogItem{name: "giantswarm-catalog"}, + catalogItem{name: "test-catalog"}, + }, + filterValue: "control", + expectedCount: 1, + }, + { + name: "fuzzy matching works", + items: []interface{}{ + catalogItem{name: "control-plane-catalog"}, + catalogItem{name: "giantswarm-catalog"}, + catalogItem{name: "test-catalog"}, + }, + filterValue: "ctlg", + expectedCount: 3, // Fuzzy should match all that contain these letters + }, + { + name: "catalog entry items", + items: []interface{}{ + catalogEntryItem{ + appName: "my-app", + version: "1.0.0", + catalog: "test-catalog", + title: "my-app 1.0.0 2024-01-01 00:00:00", + }, + catalogEntryItem{ + appName: "other-app", + version: "2.0.0", + catalog: "test-catalog", + title: "other-app 2.0.0 2024-01-02 00:00:00", + }, + }, + filterValue: "my", + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + model := newSelectorModel(tt.items, "test > ") + model.filter.SetValue(tt.filterValue) + model.filterItems() + + if len(model.filtered) != tt.expectedCount { + t.Errorf("expected %d filtered items, got %d", tt.expectedCount, len(model.filtered)) + } + }) + } +} + +func TestSelectorModel_Navigation(t *testing.T) { + items := []interface{}{ + catalogItem{name: "catalog-1"}, + catalogItem{name: "catalog-2"}, + catalogItem{name: "catalog-3"}, + } + + model := newSelectorModel(items, "test > ") + + // Test cursor starts at 0 (which renders at bottom due to reverse rendering) + if model.cursor != 0 { + t.Errorf("expected cursor to start at 0, got %d", model.cursor) + } + + // Test moving up (increases cursor since items render in reverse) + msg := tea.KeyMsg{Type: tea.KeyUp} + updatedModel, _ := model.Update(msg) + model = updatedModel.(selectorModel) + + if model.cursor != 1 { + t.Errorf("expected cursor to be at 1 after moving up, got %d", model.cursor) + } + + // Test moving down (decreases cursor) + msg = tea.KeyMsg{Type: tea.KeyDown} + updatedModel, _ = model.Update(msg) + model = updatedModel.(selectorModel) + + if model.cursor != 0 { + t.Errorf("expected cursor to be at 0 after moving down, got %d", model.cursor) + } + + // Test cursor doesn't go below 0 + msg = tea.KeyMsg{Type: tea.KeyDown} + updatedModel, _ = model.Update(msg) + model = updatedModel.(selectorModel) + + if model.cursor != 0 { + t.Errorf("expected cursor to stay at 0, got %d", model.cursor) + } +} + +func TestSelectorModel_Selection(t *testing.T) { + items := []interface{}{ + catalogItem{name: "catalog-1"}, + catalogItem{name: "catalog-2"}, + catalogItem{name: "catalog-3"}, + } + + model := newSelectorModel(items, "test > ") + + // Move to second item (up increases cursor due to reverse rendering) + msg := tea.KeyMsg{Type: tea.KeyUp} + updatedModel, _ := model.Update(msg) + model = updatedModel.(selectorModel) + + // Select the item + msg = tea.KeyMsg{Type: tea.KeyEnter} + updatedModel, _ = model.Update(msg) + model = updatedModel.(selectorModel) + + if model.selected == nil { + t.Error("expected an item to be selected") + } + + selectedItem, ok := model.selected.(catalogItem) + if !ok { + t.Error("expected selected item to be catalogItem") + } + + if selectedItem.name != "catalog-2" { + t.Errorf("expected selected item to be 'catalog-2', got '%s'", selectedItem.name) + } +} + +func TestItemSource(t *testing.T) { + items := []interface{}{ + catalogItem{name: "test-catalog"}, + catalogEntryItem{ + appName: "my-app", + version: "1.0.0", + title: "my-app 1.0.0", + }, + } + + source := itemSource(items) + + if source.Len() != 2 { + t.Errorf("expected length 2, got %d", source.Len()) + } + + if source.String(0) != "test-catalog" { + t.Errorf("expected 'test-catalog', got '%s'", source.String(0)) + } + + if source.String(1) != "my-app 1.0.0" { + t.Errorf("expected 'my-app 1.0.0', got '%s'", source.String(1)) + } +} diff --git a/cmd/deploy/spinner.go b/cmd/deploy/spinner.go new file mode 100644 index 000000000..b5f4be705 --- /dev/null +++ b/cmd/deploy/spinner.go @@ -0,0 +1,101 @@ +package deploy + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type spinnerModel struct { + spinner spinner.Model + message string + done bool + err error + quitting bool +} + +type operationCompleteMsg struct { + err error +} + +func initialSpinnerModel(message string) spinnerModel { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00aaff")) + return spinnerModel{ + spinner: s, + message: message, + } +} + +func (m spinnerModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + } + return m, nil + + case operationCompleteMsg: + m.done = true + m.err = msg.err + return m, tea.Quit + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + default: + return m, nil + } +} + +func (m spinnerModel) View() string { + if m.done { + if m.err != nil { + return errorStyle.Render("✗ ") + m.message + "\n" + } + return successStyle.Render("✓ ") + m.message + "\n" + } + + if m.quitting { + return mutedStyle.Render("Operation cancelled\n") + } + + return fmt.Sprintf("%s %s", m.spinner.View(), m.message) +} + +// RunWithSpinner runs a function with a spinner display +func RunWithSpinner(message string, fn func() error) error { + p := tea.NewProgram(initialSpinnerModel(message)) + + // Run the operation in a goroutine + go func() { + // Add a small delay to ensure the spinner starts + time.Sleep(100 * time.Millisecond) + err := fn() + p.Send(operationCompleteMsg{err: err}) + }() + + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("spinner error: %w", err) + } + + // Get the operation error from the final model + if m, ok := finalModel.(spinnerModel); ok { + return m.err + } + + return nil +} diff --git a/cmd/deploy/status.go b/cmd/deploy/status.go new file mode 100644 index 000000000..a1e696580 --- /dev/null +++ b/cmd/deploy/status.go @@ -0,0 +1,60 @@ +package deploy + +import ( + "context" + + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// checkKustomizations checks the health of all Kustomization resources +func (r *runner) checkKustomizations(ctx context.Context) (allReady bool, notReady []resourceInfo, suspended []resourceInfo, err error) { + kustomizationList := &kustomizev1.KustomizationList{} + + err = r.ctrlClient.List(ctx, kustomizationList) + if err != nil { + return false, nil, nil, err + } + + allReady = true + notReady = []resourceInfo{} + suspended = []resourceInfo{} + + for i := range kustomizationList.Items { + kust := &kustomizationList.Items[i] + + // Check if suspended + if kust.Spec.Suspend { + suspended = append(suspended, resourceInfo{ + name: kust.Name, + namespace: kust.Namespace, + }) + // Skip ready check for suspended kustomizations + continue + } + + // Check ready condition + ready := false + reason := "" + for _, cond := range kust.Status.Conditions { + if cond.Type == "Ready" { + ready = (cond.Status == metav1.ConditionTrue) + if !ready { + reason = cond.Reason + } + break + } + } + + if !ready { + allReady = false + notReady = append(notReady, resourceInfo{ + name: kust.Name, + namespace: kust.Namespace, + reason: reason, + }) + } + } + + return allReady, notReady, suspended, nil +} diff --git a/cmd/deploy/sync.go b/cmd/deploy/sync.go new file mode 100644 index 000000000..0b09d3c63 --- /dev/null +++ b/cmd/deploy/sync.go @@ -0,0 +1,64 @@ +package deploy + +import ( + "context" + "fmt" + "os/exec" +) + +// reconcileFluxSource triggers flux reconciliation for a GitRepository source +func (r *runner) reconcileFluxSource(ctx context.Context, resourceName, namespace string) error { + if !r.flag.Sync { + return nil + } + + // Check if flux CLI is available + if _, err := exec.LookPath("flux"); err != nil { + return fmt.Errorf("flux CLI not found in PATH. Please install flux CLI to use --sync flag: %w", err) + } + + //nolint:errcheck // informational output + fmt.Fprintf(r.stdout, "%s Reconciling flux source %s...\n", infoStyle.Render("→"), resourceName) + + // Run: flux reconcile source git -n + cmd := exec.CommandContext(ctx, "flux", "reconcile", "source", "git", resourceName, "-n", namespace) + cmd.Stdout = r.stdout + cmd.Stderr = r.stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to reconcile flux source: %w", err) + } + + return nil +} + +// reconcileFluxApp triggers flux reconciliation for app-related kustomizations +// Apps deployed with this tool have flux reconciliation suspended, so we reconcile +// the kustomizations in the flux-giantswarm namespace to ensure cluster-level changes are applied +func (r *runner) reconcileFluxApp(ctx context.Context, appName, namespace string) error { + if !r.flag.Sync { + return nil + } + + // Check if flux CLI is available + if _, err := exec.LookPath("flux"); err != nil { + return fmt.Errorf("flux CLI not found in PATH. Please install flux CLI to use --sync flag: %w", err) + } + + //nolint:errcheck // informational output + fmt.Fprintf(r.stdout, "%s Reconciling flux kustomizations...\n", infoStyle.Render("→")) + + // Reconcile the management-clusters-fleet kustomization which manages apps + cmd := exec.CommandContext(ctx, "flux", "reconcile", "kustomization", "collection", "-n", "flux-giantswarm") + cmd.Stdout = r.stdout + cmd.Stderr = r.stderr + + if err := cmd.Run(); err != nil { + // If management-clusters-fleet doesn't exist, that's okay - just warn + //nolint:errcheck // non-critical warning message + fmt.Fprintf(r.stderr, "Warning: failed to reconcile flux kustomizations: %v\n", err) + return nil + } + + return nil +} diff --git a/cmd/deploy/ui.go b/cmd/deploy/ui.go new file mode 100644 index 000000000..9f8cd8855 --- /dev/null +++ b/cmd/deploy/ui.go @@ -0,0 +1,680 @@ +package deploy + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + applicationv1alpha1 "github.com/giantswarm/apiextensions-application/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + // Color palette + colorSuccess = lipgloss.Color("#5f875f") + colorWarning = lipgloss.Color("#af8700") + colorError = lipgloss.Color("#af5f5f") + colorInfo = lipgloss.Color("#5f87af") + colorMuted = lipgloss.Color("#808080") + + // Styles + successStyle = lipgloss.NewStyle(). + Foreground(colorSuccess). + Bold(true) + + warningStyle = lipgloss.NewStyle(). + Foreground(colorWarning). + Bold(true) + + errorStyle = lipgloss.NewStyle(). + Foreground(colorError). + Bold(true) + + infoStyle = lipgloss.NewStyle(). + Foreground(colorInfo). + Bold(true) + + mutedStyle = lipgloss.NewStyle(). + Foreground(colorMuted) + + titleStyle = lipgloss.NewStyle(). + Bold(true). + Underline(true). + MarginBottom(1) + + boxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorInfo). + Padding(1, 2). + MarginTop(1). + MarginBottom(1) + + listItemStyle = lipgloss.NewStyle(). + PaddingLeft(2) + + reminderStyle = lipgloss.NewStyle(). + Foreground(colorWarning). + Bold(true). + MarginTop(1) + + headerStyle = lipgloss.NewStyle().Bold(true).Foreground(colorInfo) + + // ansiRegex matches ANSI escape codes + ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) +) + +// visibleWidth returns the display width of a string, excluding ANSI codes +func visibleWidth(s string) int { + return len(ansiRegex.ReplaceAllString(s, "")) +} + +// tableBuilder helps build formatted tables +type tableBuilder struct { + headers []string + rows [][]string + indent string +} + +func newTable(headers ...string) *tableBuilder { + return &tableBuilder{ + headers: headers, + rows: [][]string{}, + indent: " ", + } +} + +func (t *tableBuilder) addRow(values ...string) { + t.rows = append(t.rows, values) +} + +func (t *tableBuilder) render() string { + if len(t.rows) == 0 { + return "" + } + + // Calculate max widths for each column + colWidths := make([]int, len(t.headers)) + for i, header := range t.headers { + colWidths[i] = visibleWidth(header) + } + for _, row := range t.rows { + for i, cell := range row { + cellWidth := visibleWidth(cell) + if i < len(colWidths) && cellWidth > colWidths[i] { + colWidths[i] = cellWidth + } + } + } + + var b strings.Builder + + // Render header + headerParts := make([]string, len(t.headers)) + for i, header := range t.headers { + headerVisWidth := visibleWidth(header) + paddingNeeded := colWidths[i] - headerVisWidth + if paddingNeeded > 0 { + headerParts[i] = header + strings.Repeat(" ", paddingNeeded) + } else { + headerParts[i] = header + } + } + b.WriteString(t.indent + headerStyle.Render(strings.Join(headerParts, " ")) + "\n") + + // Render separator + totalWidth := 0 + for i, width := range colWidths { + totalWidth += width + if i < len(colWidths)-1 { + totalWidth += 2 // for " " spacing + } + } + b.WriteString(t.indent + mutedStyle.Render(strings.Repeat("─", totalWidth)) + "\n") + + // Render rows + for _, row := range t.rows { + rowParts := make([]string, len(row)) + for i, cell := range row { + if i < len(colWidths) { + // Calculate padding needed based on visible width + cellVisWidth := visibleWidth(cell) + paddingNeeded := colWidths[i] - cellVisWidth + if paddingNeeded > 0 { + rowParts[i] = cell + strings.Repeat(" ", paddingNeeded) + } else { + rowParts[i] = cell + } + } + } + b.WriteString(t.indent + strings.Join(rowParts, " ") + "\n") + } + + return b.String() +} + +// DeployOutput renders a formatted deploy success message +func DeployOutput(kind, name, version, namespace string) string { + var b strings.Builder + + // Header + b.WriteString(successStyle.Render("✓ Deployment Successful") + "\n\n") + + // Details box + details := fmt.Sprintf( + "%s: %s/%s\n%s: %s\n%s: %s", + infoStyle.Render("Resource"), + kind, + name, + infoStyle.Render("Version"), + version, + infoStyle.Render("Namespace"), + namespace, + ) + b.WriteString(boxStyle.Render(details) + "\n") + + return b.String() +} + +// ReminderOutput renders the undeploy reminder +func ReminderOutput(resourceType, name string) string { + var undeployCmd string + if resourceType == "config" { + undeployCmd = fmt.Sprintf("kubectl gs deploy -t config -u %s", name) + } else { + undeployCmd = fmt.Sprintf("kubectl gs deploy -u %s", name) + } + + reminder := fmt.Sprintf( + "%s Don't forget to undeploy after testing:\n %s", + warningStyle.Render("⚠"), + mutedStyle.Render(undeployCmd), + ) + return reminderStyle.Render(reminder) + "\n" +} + +// UpdateOutput renders a formatted update success message +func UpdateOutput(kind, name, namespace string, changes []string) string { + var b strings.Builder + + // Header + b.WriteString(successStyle.Render("✓ Update Successful") + "\n") + + // Details + details := fmt.Sprintf( + "%s: %s/%s\n%s: %s", + infoStyle.Render("Resource"), + kind, + name, + infoStyle.Render("Namespace"), + namespace, + ) + + if len(changes) > 0 { + details += "\n" + infoStyle.Render("Changes") + ":" + for _, change := range changes { + details += fmt.Sprintf("\n • %s", change) + } + } + + b.WriteString(boxStyle.Render(details) + "\n") + + return b.String() +} + +// UndeployOutput renders a formatted undeploy success message +func UndeployOutput(kind, name, namespace string, changes []string) string { + var b strings.Builder + + // Header + b.WriteString(successStyle.Render("✓ Undeployment Successful") + "\n") + + // Details + details := fmt.Sprintf( + "%s: %s/%s\n%s: %s", + infoStyle.Render("Resource"), + kind, + name, + infoStyle.Render("Namespace"), + namespace, + ) + + if len(changes) > 0 { + details += "\n" + infoStyle.Render("Changes") + ":" + for _, change := range changes { + details += fmt.Sprintf("\n • %s", change) + } + } + + b.WriteString(boxStyle.Render(details) + "\n") + + return b.String() +} + +// StatusOutput renders a formatted status display +func StatusOutput( + kustomizationsReady bool, + notReadyKustomizations []resourceInfo, + suspendedKustomizations []resourceInfo, + suspendedApps []resourceInfo, + suspendedGitRepos []resourceInfo, +) string { + var b strings.Builder + + // Header + b.WriteString(titleStyle.Render("📊 Deployment Status") + "\n\n") + + // Overall health check + allHealthy := kustomizationsReady && len(suspendedKustomizations) == 0 && + len(suspendedApps) == 0 && len(suspendedGitRepos) == 0 + + if allHealthy { + healthMsg := successStyle.Render("✓ All Systems Healthy") + b.WriteString(boxStyle.Render(healthMsg) + "\n") + return b.String() + } + + // Show issues + b.WriteString(warningStyle.Render("⚠ Issues Detected") + "\n\n") + + // Not ready kustomizations + if !kustomizationsReady && len(notReadyKustomizations) > 0 { + b.WriteString(errorStyle.Render("✗ Not Ready Kustomizations:") + "\n") + for _, k := range notReadyKustomizations { + item := fmt.Sprintf("• %s/%s", k.namespace, k.name) + if k.reason != "" { + item += mutedStyle.Render(fmt.Sprintf(" (reason: %s)", k.reason)) + } + b.WriteString(listItemStyle.Render(item) + "\n") + } + b.WriteString("\n") + } + + // Suspended kustomizations + if len(suspendedKustomizations) > 0 { + b.WriteString(warningStyle.Render("⚠ Suspended Kustomizations:") + "\n\n") + table := newTable("NAME", "NAMESPACE") + for _, kust := range suspendedKustomizations { + table.addRow(kust.name, kust.namespace) + } + b.WriteString(table.render() + "\n") + } + + // Suspended apps + if len(suspendedApps) > 0 { + b.WriteString(warningStyle.Render("⚠ Suspended Apps:") + "\n\n") + table := newTable("NAME", "NAMESPACE", "VERSION", "CATALOG", "STATUS") + for _, app := range suspendedApps { + version := app.version + if version == "" { + version = "-" + } + catalog := app.catalog + if catalog == "" { + catalog = "-" + } + status := app.status + if status == "" { + status = "Unknown" + } + table.addRow(app.name, app.namespace, version, catalog, colorizeStatus(status)) + } + b.WriteString(table.render() + "\n") + } + + // Suspended git repositories + if len(suspendedGitRepos) > 0 { + b.WriteString(warningStyle.Render("⚠ Suspended Git Repositories:") + "\n\n") + table := newTable("NAME", "NAMESPACE", "BRANCH", "URL", "STATUS") + for _, repo := range suspendedGitRepos { + branch := repo.branch + if branch == "" { + branch = "-" + } + url := repo.url + if url == "" { + url = "-" + } + status := repo.status + if status == "" { + status = "Unknown" + } + table.addRow(repo.name, repo.namespace, branch, url, colorizeStatus(status)) + } + b.WriteString(table.render() + "\n") + } + + return b.String() +} + +// ErrorOutput renders a formatted error message +func ErrorOutput(err error) string { + var b strings.Builder + + b.WriteString(errorStyle.Render("✗ Error") + "\n\n") + b.WriteString(boxStyle. + BorderForeground(colorError). + Render(err.Error()) + "\n") + + return b.String() +} + +// InfoOutput renders a formatted info message +func InfoOutput(message string) string { + return infoStyle.Render("ℹ ") + message + "\n" +} + +// ListAppsOutput renders a formatted list of applications +func ListAppsOutput(apps []appInfo, namespace string, catalog string, installedOnly bool) string { + var b strings.Builder + + b.WriteString(titleStyle.Render("📦 Applications") + "\n") + if installedOnly { + b.WriteString(mutedStyle.Render(fmt.Sprintf("Catalog: %s | Namespace: %s (installed only)", catalog, namespace)) + "\n\n") + } else { + b.WriteString(mutedStyle.Render(fmt.Sprintf("Catalog: %s | Namespace: %s", catalog, namespace)) + "\n\n") + } + + if len(apps) == 0 { + b.WriteString(warningStyle.Render("No apps found") + "\n") + return b.String() + } + + // Sort apps by name + sortedApps := make([]appInfo, len(apps)) + copy(sortedApps, apps) + sort.Slice(sortedApps, func(i, j int) bool { + return sortedApps[i].name < sortedApps[j].name + }) + + // Build table + var table *tableBuilder + if installedOnly { + table = newTable("NAME", "VERSION", "CATALOG", "STATUS") + } else { + table = newTable("NAME", "INSTALLED", "VERSION", "CATALOG", "STATUS") + } + + installedCount := 0 + for _, app := range sortedApps { + version := app.version + if version == "" { + version = "-" + } + catalog := app.catalog + if catalog == "" { + catalog = "-" + } + status := colorizeStatus(app.status) + + if installedOnly { + // When showing installed only, all apps are installed + table.addRow(app.name, version, catalog, status) + installedCount++ + } else { + // Show installation status + var installedStatus string + if app.installed { + installedStatus = successStyle.Render("Yes") + installedCount++ + } else { + installedStatus = mutedStyle.Render("No") + } + table.addRow(app.name, installedStatus, version, catalog, status) + } + } + + b.WriteString(table.render()) + if installedOnly { + b.WriteString("\n" + mutedStyle.Render(fmt.Sprintf("Total: %d apps", len(apps))) + "\n") + } else { + b.WriteString("\n" + mutedStyle.Render(fmt.Sprintf("Total: %d apps (%d installed)", len(apps), installedCount)) + "\n") + } + return b.String() +} + +// ListVersionsOutput renders a formatted list of application versions +func ListVersionsOutput(appName string, entries *applicationv1alpha1.AppCatalogEntryList, deployedVersion string, deployedCatalog string) string { + var b strings.Builder + + // Header + b.WriteString(titleStyle.Render(fmt.Sprintf("📋 Versions for %s", appName)) + "\n\n") + + if len(entries.Items) == 0 { + b.WriteString(warningStyle.Render("No versions found") + "\n") + return b.String() + } + + // Sort versions first by catalog (alphabetically), then by version (most recent first) + sortedEntries := make([]applicationv1alpha1.AppCatalogEntry, len(entries.Items)) + copy(sortedEntries, entries.Items) + sort.Slice(sortedEntries, func(i, j int) bool { + // First sort by catalog name (ascending) + if sortedEntries[i].Spec.Catalog.Name != sortedEntries[j].Spec.Catalog.Name { + return sortedEntries[i].Spec.Catalog.Name < sortedEntries[j].Spec.Catalog.Name + } + // Then sort by version (descending) + return sortedEntries[i].Spec.Version > sortedEntries[j].Spec.Version + }) + + // Display versions + for i, entry := range sortedEntries { + versionInfo := fmt.Sprintf( + "%s %s", + infoStyle.Render("•"), + entry.Spec.Version, + ) + + // Mark the latest version + if i == 0 { + versionInfo += " " + successStyle.Render("(latest)") + } + + // Mark the deployed version (match both version and catalog) + if deployedVersion != "" && entry.Spec.Version == deployedVersion && + deployedCatalog != "" && entry.Spec.Catalog.Name == deployedCatalog { + versionInfo += " " + warningStyle.Render("(deployed)") + } + + // Add catalog info if available + if entry.Spec.Catalog.Name != "" { + versionInfo += mutedStyle.Render(fmt.Sprintf(" [%s]", entry.Spec.Catalog.Name)) + } + + b.WriteString(listItemStyle.Render(versionInfo) + "\n") + } + + b.WriteString("\n" + mutedStyle.Render(fmt.Sprintf("Total: %d versions", len(entries.Items))) + "\n") + return b.String() +} + +// PRInfo represents a GitHub Pull Request for config versions +type PRInfo struct { + Number int + Title string + HeadRefName string + Author struct { + Login string + } + CreatedAt string +} + +// ListConfigVersionsOutput renders a formatted list of config repository versions (PRs) +func ListConfigVersionsOutput(configName string, prs []PRInfo, currentBranch string, githubRepo string) string { + var b strings.Builder + + // Header + b.WriteString(titleStyle.Render(fmt.Sprintf("📋 Versions (PRs) for %s", configName)) + "\n") + b.WriteString(mutedStyle.Render(fmt.Sprintf("Repository: %s", githubRepo)) + "\n\n") + + if len(prs) == 0 { + b.WriteString(warningStyle.Render("No open PRs found") + "\n") + if currentBranch != "" { + b.WriteString(mutedStyle.Render(fmt.Sprintf("\nCurrent branch: %s", currentBranch)) + "\n") + } + return b.String() + } + + // Display PRs + for _, pr := range prs { + // Format: • (deployed) [PR #: by @<author>] + prDisplay := fmt.Sprintf( + "%s %s", + infoStyle.Render("•"), + pr.HeadRefName, + ) + + // Mark the current branch + if currentBranch != "" && pr.HeadRefName == currentBranch { + prDisplay += " " + warningStyle.Render("(deployed)") + } + + // Add PR info + prDisplay += mutedStyle.Render(fmt.Sprintf(" [PR #%d: %s by @%s]", pr.Number, pr.Title, pr.Author.Login)) + + b.WriteString(listItemStyle.Render(prDisplay) + "\n") + } + + b.WriteString("\n" + mutedStyle.Render(fmt.Sprintf("Total: %d open PRs", len(prs))) + "\n") + if currentBranch != "" { + b.WriteString(mutedStyle.Render(fmt.Sprintf("Current branch: %s", currentBranch)) + "\n") + } + return b.String() +} + +// ListConfigsOutput renders a formatted list of config repositories +func ListConfigsOutput(gitRepoList *sourcev1.GitRepositoryList, namespace string) string { + var b strings.Builder + + b.WriteString(titleStyle.Render("⚙️ Config Repositories") + "\n") + b.WriteString(mutedStyle.Render(fmt.Sprintf("Namespace: %s", namespace)) + "\n\n") + + if len(gitRepoList.Items) == 0 { + b.WriteString(warningStyle.Render("No config repositories found") + "\n") + return b.String() + } + + // Sort by name + sortedRepos := make([]sourcev1.GitRepository, len(gitRepoList.Items)) + copy(sortedRepos, gitRepoList.Items) + sort.Slice(sortedRepos, func(i, j int) bool { + return sortedRepos[i].Name < sortedRepos[j].Name + }) + + // Build table + table := newTable("NAME", "BRANCH", "URL", "STATUS") + for i := range sortedRepos { + repo := &sortedRepos[i] + branch := "" + if repo.Spec.Reference != nil { + branch = repo.Spec.Reference.Branch + } + if branch == "" { + branch = "-" + } + status := colorizeStatus(getGitRepoStatus(repo)) + table.addRow(repo.Name, branch, repo.Spec.URL, status) + } + + b.WriteString(table.render()) + b.WriteString("\n" + mutedStyle.Render(fmt.Sprintf("Total: %d repositories", len(gitRepoList.Items))) + "\n") + return b.String() +} + +// ListCatalogsOutput renders a formatted list of catalogs +func ListCatalogsOutput(catalogList *applicationv1alpha1.CatalogList) string { + var b strings.Builder + + b.WriteString(titleStyle.Render("📚 Catalogs") + "\n\n") + + if len(catalogList.Items) == 0 { + b.WriteString(warningStyle.Render("No catalogs found") + "\n") + return b.String() + } + + // Sort by name + sortedCatalogs := make([]applicationv1alpha1.Catalog, len(catalogList.Items)) + copy(sortedCatalogs, catalogList.Items) + sort.Slice(sortedCatalogs, func(i, j int) bool { + return sortedCatalogs[i].Name < sortedCatalogs[j].Name + }) + + // Build table + table := newTable("NAME", "NAMESPACE", "URL") + for _, cat := range sortedCatalogs { + url := getCatalogURL(&cat) + table.addRow(cat.Name, cat.Namespace, url) + } + + b.WriteString(table.render()) + b.WriteString("\n" + mutedStyle.Render(fmt.Sprintf("Total: %d catalogs", len(catalogList.Items))) + "\n") + return b.String() +} + +// getCatalogURL extracts the URL from a Catalog CR +func getCatalogURL(cat *applicationv1alpha1.Catalog) string { + // Try the new way first (repositories) + if len(cat.Spec.Repositories) > 0 { + return cat.Spec.Repositories[0].URL + } + // Fall back to the legacy way + if cat.Spec.Storage.URL != "" { + return cat.Spec.Storage.URL + } + return "-" +} + +// getAppStatus extracts the status from an App CR +func getAppStatus(app *applicationv1alpha1.App) string { + // Check if app has release status + status := app.Status.Release.Status + if status == "" { + return "Unknown" + } + + return status +} + +// getGitRepoStatus extracts the status from a GitRepository CR +func getGitRepoStatus(repo *sourcev1.GitRepository) string { + for _, cond := range repo.Status.Conditions { + if cond.Type == "Ready" { + if cond.Status == metav1.ConditionTrue { + return "Ready" + } + // Not ready - show the reason + if cond.Reason != "" { + return cond.Reason + } + if cond.Message != "" { + return cond.Message + } + return "Not Ready" + } + } + return "Unknown" +} + +// colorizeStatus applies color to status text based on the status value +func colorizeStatus(status string) string { + // Empty or dash status (muted) + if status == "-" || status == "" { + return mutedStyle.Render("-") + } + + // Success statuses + if status == "deployed" || status == "Ready" { + return successStyle.Render(status) + } + + // Warning statuses + statusLower := strings.ToLower(status) + if status == "Unknown" || strings.Contains(statusLower, "pending") || strings.Contains(statusLower, "progressing") { + return warningStyle.Render(status) + } + + // Everything else is treated as an error + return errorStyle.Render(status) +} diff --git a/cmd/deploy/ui_test.go b/cmd/deploy/ui_test.go new file mode 100644 index 000000000..d8fe00507 --- /dev/null +++ b/cmd/deploy/ui_test.go @@ -0,0 +1,448 @@ +package deploy + +import ( + "strings" + "testing" + + applicationv1alpha1 "github.com/giantswarm/apiextensions-application/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestListVersionsOutput_SortsByVersionThenCatalog(t *testing.T) { + testCases := []struct { + name string + entries []applicationv1alpha1.AppCatalogEntry + expectedOrder []string // Expected version and catalog in order: "version:catalog" + deployedVer string + deployedCat string + }{ + { + name: "sorts by catalog ascending, then version descending", + entries: []applicationv1alpha1.AppCatalogEntry{ + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "1.0.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "giantswarm", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "2.0.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "community", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "2.0.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "giantswarm", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "1.5.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "giantswarm", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "2.0.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "azure", + }, + }, + }, + }, + expectedOrder: []string{ + "2.0.0:azure", + "2.0.0:community", + "2.0.0:giantswarm", + "1.5.0:giantswarm", + "1.0.0:giantswarm", + }, + }, + { + name: "sorts with same version across multiple catalogs", + entries: []applicationv1alpha1.AppCatalogEntry{ + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "3.0.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "zzz-catalog", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "3.0.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "aaa-catalog", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "3.0.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "mmm-catalog", + }, + }, + }, + }, + expectedOrder: []string{ + "3.0.0:aaa-catalog", + "3.0.0:mmm-catalog", + "3.0.0:zzz-catalog", + }, + }, + { + name: "real-world example with control-plane-catalog and giantswarm", + entries: []applicationv1alpha1.AppCatalogEntry{ + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "0.14.1", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "giantswarm", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "0.14.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "control-plane-catalog", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "0.14.1", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "control-plane-catalog", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "0.13.4", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "giantswarm", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "0.13.4", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "control-plane-catalog", + }, + }, + }, + { + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "0.14.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "giantswarm", + }, + }, + }, + }, + expectedOrder: []string{ + "0.14.1:control-plane-catalog", + "0.14.0:control-plane-catalog", + "0.13.4:control-plane-catalog", + "0.14.1:giantswarm", + "0.14.0:giantswarm", + "0.13.4:giantswarm", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + entries := &applicationv1alpha1.AppCatalogEntryList{ + Items: tc.entries, + } + + output := ListVersionsOutput("test-app", entries, tc.deployedVer, tc.deployedCat) + + // Parse the output to extract the order of versions and catalogs + lines := strings.Split(output, "\n") + var actualOrder []string + + for _, line := range lines { + // Look for lines that contain version info + // Format is: " • VERSION [CATALOG]" + if strings.Contains(line, "•") && strings.Contains(line, "[") { + // Extract version and catalog + // Remove ANSI color codes for testing + cleanLine := removeANSICodes(line) + // Parse the line to extract version and catalog + parts := strings.Fields(cleanLine) + if len(parts) >= 3 { + version := parts[1] + // Extract catalog from [catalog] format + catalogPart := parts[len(parts)-1] + catalog := strings.Trim(catalogPart, "[]") + actualOrder = append(actualOrder, version+":"+catalog) + } + } + } + + // Verify the order matches expectations + if len(actualOrder) != len(tc.expectedOrder) { + t.Fatalf("Expected %d entries, got %d.\nExpected: %v\nActual: %v", + len(tc.expectedOrder), len(actualOrder), tc.expectedOrder, actualOrder) + } + + for i, expected := range tc.expectedOrder { + if actualOrder[i] != expected { + t.Errorf("At position %d: expected %s, got %s", i, expected, actualOrder[i]) + } + } + }) + } +} + +// removeANSICodes removes ANSI color codes from a string for easier testing +func removeANSICodes(s string) string { + // Simple approach: iterate through and remove escape sequences + var result strings.Builder + inEscape := false + + for i := 0; i < len(s); i++ { + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { + inEscape = true + i++ // skip the '[' + continue + } + if inEscape { + if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') { + inEscape = false + } + continue + } + result.WriteByte(s[i]) + } + + return result.String() +} + +func TestListVersionsOutput_EmptyList(t *testing.T) { + entries := &applicationv1alpha1.AppCatalogEntryList{ + Items: []applicationv1alpha1.AppCatalogEntry{}, + } + + output := ListVersionsOutput("test-app", entries, "", "") + + if !strings.Contains(output, "No versions found") { + t.Errorf("Expected 'No versions found' message, got: %s", output) + } +} + +func TestListVersionsOutput_MarksDeployedVersion(t *testing.T) { + entries := &applicationv1alpha1.AppCatalogEntryList{ + Items: []applicationv1alpha1.AppCatalogEntry{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app-1.0.0", + }, + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "1.0.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "giantswarm", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app-2.0.0", + }, + Spec: applicationv1alpha1.AppCatalogEntrySpec{ + Version: "2.0.0", + Catalog: applicationv1alpha1.AppCatalogEntrySpecCatalog{ + Name: "giantswarm", + }, + }, + }, + }, + } + + output := ListVersionsOutput("test-app", entries, "1.0.0", "giantswarm") + + // Should contain deployed marker for version 1.0.0 + if !strings.Contains(output, "deployed") { + t.Errorf("Expected output to mark deployed version, got: %s", output) + } +} + +func TestStatusOutput_WithVersionInfo(t *testing.T) { + testCases := []struct { + name string + kustomizationsReady bool + notReadyKustomizations []resourceInfo + suspendedKustomizations []resourceInfo + suspendedApps []resourceInfo + suspendedGitRepos []resourceInfo + expectedContains []string + }{ + { + name: "all healthy", + kustomizationsReady: true, + notReadyKustomizations: []resourceInfo{}, + suspendedKustomizations: []resourceInfo{}, + suspendedApps: []resourceInfo{}, + suspendedGitRepos: []resourceInfo{}, + expectedContains: []string{"All Systems Healthy"}, + }, + { + name: "suspended apps with version info", + kustomizationsReady: true, + notReadyKustomizations: []resourceInfo{}, + suspendedKustomizations: []resourceInfo{}, + suspendedApps: []resourceInfo{ + { + name: "my-app", + namespace: "default", + version: "1.2.3", + catalog: "giantswarm", + status: "deployed", + }, + { + name: "another-app", + namespace: "org-example", + version: "2.0.0", + catalog: "control-plane-catalog", + status: "deployed", + }, + }, + suspendedGitRepos: []resourceInfo{}, + expectedContains: []string{ + "Suspended Apps", + "my-app", + "1.2.3", + "giantswarm", + "deployed", + "another-app", + "2.0.0", + "control-plane-catalog", + "NAME", + "NAMESPACE", + "VERSION", + "CATALOG", + "STATUS", + }, + }, + { + name: "suspended git repos with branch info", + kustomizationsReady: true, + notReadyKustomizations: []resourceInfo{}, + suspendedKustomizations: []resourceInfo{}, + suspendedApps: []resourceInfo{}, + suspendedGitRepos: []resourceInfo{ + { + name: "config-repo", + namespace: "default", + branch: "main", + url: "https://github.com/giantswarm/config-repo", + status: "Ready", + }, + { + name: "another-config", + namespace: "org-example", + branch: "v1.0.0", + url: "https://github.com/giantswarm/another-config", + status: "Ready", + }, + }, + expectedContains: []string{ + "Suspended Git Repositories", + "config-repo", + "main", + "https://github.com/giantswarm/config-repo", + "Ready", + "another-config", + "v1.0.0", + "https://github.com/giantswarm/another-config", + "NAME", + "NAMESPACE", + "BRANCH", + "URL", + "STATUS", + }, + }, + { + name: "apps without version info", + kustomizationsReady: true, + notReadyKustomizations: []resourceInfo{}, + suspendedKustomizations: []resourceInfo{}, + suspendedApps: []resourceInfo{ + { + name: "app-no-version", + namespace: "default", + version: "", + catalog: "", + status: "Unknown", + }, + }, + suspendedGitRepos: []resourceInfo{}, + expectedContains: []string{ + "Suspended Apps", + "app-no-version", + "-", // Should show dash for empty values + }, + }, + { + name: "suspended kustomizations", + kustomizationsReady: true, + notReadyKustomizations: []resourceInfo{}, + suspendedKustomizations: []resourceInfo{ + { + name: "flux-system", + namespace: "flux-system", + }, + { + name: "my-app-kustomization", + namespace: "default", + }, + }, + suspendedApps: []resourceInfo{}, + suspendedGitRepos: []resourceInfo{}, + expectedContains: []string{ + "Suspended Kustomizations", + "flux-system", + "my-app-kustomization", + "NAME", + "NAMESPACE", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + output := StatusOutput( + tc.kustomizationsReady, + tc.notReadyKustomizations, + tc.suspendedKustomizations, + tc.suspendedApps, + tc.suspendedGitRepos, + ) + + // Remove ANSI codes for easier testing + cleanOutput := removeANSICodes(output) + + for _, expected := range tc.expectedContains { + if !strings.Contains(cleanOutput, expected) { + t.Errorf("Expected output to contain %q, but it didn't.\nOutput:\n%s", expected, cleanOutput) + } + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index 8454ce955..bdd21fbef 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" + "github.com/giantswarm/kubectl-gs/v5/cmd/deploy" "github.com/giantswarm/kubectl-gs/v5/cmd/get" "github.com/giantswarm/kubectl-gs/v5/cmd/gitops" "github.com/giantswarm/kubectl-gs/v5/cmd/login" @@ -229,6 +230,24 @@ func New(config Config) (*cobra.Command, error) { return nil, microerror.Mask(err) } } + + var deployCmd *cobra.Command + { + c := deploy.Config{ + Logger: config.Logger, + FileSystem: config.FileSystem, + ConfigFlags: &f.config, + Stderr: config.Stderr, + Stdout: config.Stdout, + } + + deployCmd, err = deploy.New(c) + if err != nil { + return nil, microerror.Mask(err) + } + } + + c.AddCommand(deployCmd) c.AddCommand(getCmd) c.AddCommand(gitopsCmd) c.AddCommand(loginCmd) diff --git a/go.mod b/go.mod index 7bcf3157a..dddd099cf 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,13 @@ require ( github.com/ProtonMail/gopenpgp/v3 v3.3.0 github.com/blang/semver v3.5.1+incompatible github.com/blang/semver/v4 v4.0.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/coreos/go-oidc/v3 v3.17.0 github.com/fatih/color v1.18.0 + github.com/fluxcd/kustomize-controller/api v1.3.0 + github.com/fluxcd/source-controller/api v1.3.0 github.com/getsops/sops/v3 v3.11.0 github.com/giantswarm/apiextensions-application v0.6.2 github.com/giantswarm/apiextensions/v6 v6.6.0 @@ -31,6 +36,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/pkg/errors v0.9.1 github.com/rhysd/go-github-selfupdate v1.2.3 + github.com/sahilm/fuzzy v0.1.1 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 @@ -66,6 +72,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/asaskevich/govalidator/v11 v11.0.2-0.20250122183457-e11347878e23 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.9 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 // indirect @@ -73,20 +80,37 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.88.3 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fluxcd/pkg/apis/acl v0.3.0 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.5.0 // indirect + github.com/fluxcd/pkg/apis/meta v1.5.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect diff --git a/go.sum b/go.sum index 6c5ea8dcc..eb0ae018f 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4t github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/asaskevich/govalidator/v11 v11.0.2-0.20250122183457-e11347878e23 h1:I+Cy77zrFmVWIHOZaxiNV4L7w9xuVux9LMqAblGzvdE= github.com/asaskevich/govalidator/v11 v11.0.2-0.20250122183457-e11347878e23/go.mod h1:S7DsXubvw3xBC8rSI+qmzcTNw7xEND0ojHPqglh/whY= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= @@ -130,6 +132,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47 github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8= github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -142,6 +146,20 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= @@ -175,6 +193,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -183,6 +203,16 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/kustomize-controller/api v1.3.0 h1:IwXkU48lQ/YhU6XULlPXDgQlnpNyQdCNbUvhLdWVIbE= +github.com/fluxcd/kustomize-controller/api v1.3.0/go.mod h1:kg/WM9Uye5NOqGVW/F3jnkjrlgFZHHa84+4lnzOV8fI= +github.com/fluxcd/pkg/apis/acl v0.3.0 h1:UOrKkBTOJK+OlZX7n8rWt2rdBmDCoTK+f5TY2LcZi8A= +github.com/fluxcd/pkg/apis/acl v0.3.0/go.mod h1:WVF9XjSMVBZuU+HTTiSebGAWMgM7IYexFLyVWbK9bNY= +github.com/fluxcd/pkg/apis/kustomize v1.5.0 h1:ah4sfqccnio+/5Edz/tVz6LetFhiBoDzXAElj6fFCzU= +github.com/fluxcd/pkg/apis/kustomize v1.5.0/go.mod h1:nEzhnhHafhWOUUV8VMFLojUOH+HHDEsL75y54mt/c30= +github.com/fluxcd/pkg/apis/meta v1.5.0 h1:/G82d2Az5D9op3F+wJUpD8jw/eTV0suM6P7+cSURoUM= +github.com/fluxcd/pkg/apis/meta v1.5.0/go.mod h1:Y3u7JomuuKtr5fvP1Iji2/50FdRe5GcBug2jawNVkdM= +github.com/fluxcd/source-controller/api v1.3.0 h1:Z5Lq0aJY87yg0cQDEuwGLKS60GhdErCHtsi546HUt10= +github.com/fluxcd/source-controller/api v1.3.0/go.mod h1:+tfd0vltjcVs/bbnq9AlYR9AAHSVfM/Z4v4TpQmdJf4= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -353,12 +383,18 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -384,6 +420,12 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -424,6 +466,9 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag= github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -432,6 +477,8 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= @@ -489,6 +536,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -577,6 +626,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/list/go.mod b/list/go.mod new file mode 100644 index 000000000..f1359d336 --- /dev/null +++ b/list/go.mod @@ -0,0 +1,31 @@ +module test + +go 1.25.5 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/sahilm/fuzzy v0.1.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/list/go.sum b/list/go.sum new file mode 100644 index 000000000..a5aaf28fc --- /dev/null +++ b/list/go.sum @@ -0,0 +1,55 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/list/main.go b/list/main.go new file mode 100644 index 000000000..fd93c5370 --- /dev/null +++ b/list/main.go @@ -0,0 +1,259 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sahilm/fuzzy" +) + +var ( + selectedItemStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")) + normalItemStyle = lipgloss.NewStyle() + promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + matchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")).Underline(true) + selectedMatchStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("211")).Underline(true) +) + +type item struct { + title string +} + +func (i item) String() string { + return i.title +} + +type itemMatch struct { + item item + positions []int +} + +type model struct { + items []item + filtered []itemMatch + cursor int + filter textinput.Model + width int + height int +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m *model) filterItems() { + query := m.filter.Value() + if query == "" { + m.filtered = make([]itemMatch, len(m.items)) + for i, item := range m.items { + m.filtered[i] = itemMatch{item: item, positions: nil} + } + } else { + // Use fuzzy matching library + matches := fuzzy.FindFrom(query, itemSource(m.items)) + m.filtered = make([]itemMatch, len(matches)) + for i, match := range matches { + m.filtered[i] = itemMatch{ + item: m.items[match.Index], + positions: match.MatchedIndexes, + } + } + } + // Reset cursor if out of bounds + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + } + if m.cursor < 0 && len(m.filtered) > 0 { + m.cursor = 0 + } +} + +// itemSource implements fuzzy.Source interface +type itemSource []item + +func (s itemSource) String(i int) string { + return s[i].title +} + +func (s itemSource) Len() int { + return len(s) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "enter": + if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { + fmt.Println(m.filtered[m.cursor].item.title) + } + return m, tea.Quit + case "up", "ctrl+k": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case "down", "ctrl+j": + if m.cursor < len(m.filtered)-1 { + m.cursor++ + } + return m, nil + default: + // Update the filter input + m.filter, cmd = m.filter.Update(msg) + m.filterItems() + return m, cmd + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + } + + return m, nil +} + +// highlightMatches highlights the matching characters at given positions +func highlightMatches(text string, positions []int, isSelected bool) string { + if len(positions) == 0 { + if isSelected { + return selectedItemStyle.Render(text) + } + return normalItemStyle.Render(text) + } + + // Create a set of matched positions for quick lookup + matchedPos := make(map[int]bool) + for _, pos := range positions { + matchedPos[pos] = true + } + + var result strings.Builder + textRunes := []rune(text) + + for i, char := range textRunes { + if matchedPos[i] { + if isSelected { + result.WriteString(selectedMatchStyle.Render(string(char))) + } else { + result.WriteString(matchStyle.Render(string(char))) + } + } else { + if isSelected { + result.WriteString(selectedItemStyle.Render(string(char))) + } else { + result.WriteString(normalItemStyle.Render(string(char))) + } + } + } + + return result.String() +} + +func (m model) View() string { + var s strings.Builder + + // Calculate how many items we can show + availableLines := m.height - 2 // Reserve 2 lines for prompt + + // Determine which items to show (centered around cursor) + start := 0 + end := len(m.filtered) + + if end > availableLines { + // Center the view around the cursor + start = m.cursor - availableLines/2 + if start < 0 { + start = 0 + } + end = start + availableLines + if end > len(m.filtered) { + end = len(m.filtered) + start = end - availableLines + if start < 0 { + start = 0 + } + } + } + + // Add empty lines at the top to push items to bottom + itemCount := end - start + for i := itemCount; i < availableLines; i++ { + s.WriteString("\n") + } + + // Render items in reverse order (bottom-up, with first item closest to prompt) + for i := end - 1; i >= start; i-- { + s.WriteString(highlightMatches(m.filtered[i].item.title, m.filtered[i].positions, i == m.cursor)) + s.WriteString("\n") + } + + // Render prompt at the bottom + s.WriteString(promptStyle.Render("> ")) + s.WriteString(m.filter.View()) + + return s.String() +} + +func main() { + items := []item{ + {title: "Raspberry Pi's"}, + {title: "Nutella"}, + {title: "Bitter melon"}, + {title: "Nice socks"}, + {title: "Eight hours of sleep"}, + {title: "Cats"}, + {title: "Plantasia, the album"}, + {title: "Pour over coffee"}, + {title: "VR"}, + {title: "Noguchi Lamps"}, + {title: "Linux"}, + {title: "Business school"}, + {title: "Pottery"}, + {title: "Shampoo"}, + {title: "Table tennis"}, + {title: "Milk crates"}, + {title: "Afternoon tea"}, + {title: "Stickers"}, + {title: "20° Weather"}, + {title: "Warm light"}, + {title: "The vernal equinox"}, + {title: "Gaffer's tape"}, + {title: "Terrycloth"}, + } + + // Initialize text input for filtering + ti := textinput.New() + ti.Placeholder = "" + ti.Focus() + ti.CharLimit = 156 + ti.Width = 50 + + // Initialize filtered list + filtered := make([]itemMatch, len(items)) + for i, item := range items { + filtered[i] = itemMatch{item: item, positions: nil} + } + + m := model{ + items: items, + filtered: filtered, + cursor: 0, + filter: ti, + } + + p := tea.NewProgram(m, tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} diff --git a/pkg/data/domain/app/error.go b/pkg/data/domain/app/error.go index 24a882e0c..88ee60414 100644 --- a/pkg/data/domain/app/error.go +++ b/pkg/data/domain/app/error.go @@ -1,6 +1,8 @@ package app import ( + "errors" + "github.com/giantswarm/microerror" ) @@ -57,3 +59,9 @@ var fetchError = µerror.Error{ func IsFetch(err error) bool { return microerror.Cause(err) == fetchError } + +var ( + ErrNoMatch = errors.New("no match") + ErrNoResources = errors.New("no resources") + ErrNotFound = errors.New("not found") +) diff --git a/pkg/data/domain/app/service.go b/pkg/data/domain/app/service.go index 5a5b8cd27..869374005 100644 --- a/pkg/data/domain/app/service.go +++ b/pkg/data/domain/app/service.go @@ -6,9 +6,9 @@ import ( "net/http" applicationv1alpha1 "github.com/giantswarm/apiextensions-application/api/v1alpha1" + gsapp "github.com/giantswarm/app/v7/pkg/app" "github.com/giantswarm/appcatalog" k8smetadataAnnotation "github.com/giantswarm/k8smetadata/pkg/annotation" - k8smetadataLabel "github.com/giantswarm/k8smetadata/pkg/label" "github.com/giantswarm/microerror" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" @@ -83,6 +83,96 @@ func (s *Service) Get(ctx context.Context, options GetOptions) (Resource, error) return resource, nil } +// GetApp fetches a single app CR by namespace and name, returning the concrete type. +func (s *Service) GetApp(ctx context.Context, namespace, name string) (*applicationv1alpha1.App, error) { + var err error + + appCR := &applicationv1alpha1.App{} + err = s.client.Get(ctx, client.ObjectKey{ + Namespace: namespace, + Name: name, + }, appCR) + if apierrors.IsNotFound(err) { + return nil, ErrNotFound + } else if meta.IsNoMatchError(err) { + return nil, ErrNoMatch + } else if err != nil { + return nil, err + } + + appCR = omitManagedFields(appCR) + appCR.TypeMeta = metav1.TypeMeta{ + APIVersion: "app.application.giantswarm.io/v1alpha1", + Kind: "App", + } + + return appCR, nil +} + +// ListApps fetches all app CRs in a namespace, returning the concrete AppList type. +func (s *Service) ListApps(ctx context.Context, namespace string) (*applicationv1alpha1.AppList, error) { + var err error + + apps := &applicationv1alpha1.AppList{} + + { + lo := &client.ListOptions{ + Namespace: namespace, + } + + err = s.client.List(ctx, apps, lo) + if meta.IsNoMatchError(err) { + return nil, ErrNoMatch + } else if err != nil { + return nil, err + } else if len(apps.Items) == 0 { + return nil, ErrNoResources + } + } + + // Clean up managed fields for each app + for i := range apps.Items { + apps.Items[i].ManagedFields = nil + } + + return apps, nil +} + +// Create creates a new app CR using the same approach as architect. +func (s *Service) Create(ctx context.Context, options CreateOptions) (*applicationv1alpha1.App, error) { + // Use the giantswarm/app package to create the App CR with proper defaults + config := gsapp.Config{ + Name: options.Name, + Namespace: options.Namespace, + AppName: options.AppName, + AppNamespace: options.AppNamespace, + AppCatalog: options.AppCatalog, + AppVersion: options.AppVersion, + ConfigVersion: options.ConfigVersion, + DisableForceUpgrade: options.DisableForceUpgrade, + UserConfigMapName: options.UserConfigMapName, + UserSecretName: options.UserSecretName, + } + + appCR := gsapp.NewCR(config) + + // Validate that the version exists in the catalog + if options.AppVersion != "" { + err := s.findVersion(ctx, appCR, options.AppVersion, options.AppCatalog, options.Namespace) + if err != nil { + return nil, microerror.Mask(err) + } + } + + // Create the App CR in the cluster + err := s.client.Create(ctx, appCR) + if err != nil { + return nil, microerror.Mask(err) + } + + return appCR, nil +} + // Patch patches an app CR given its name and namespace. func (s *Service) Patch(ctx context.Context, options PatchOptions) ([]string, error) { state, err := s.patchVersion(ctx, options.Namespace, options.Name, options.SuspendReconciliation, options.Version) @@ -138,26 +228,28 @@ func (s *Service) patchVersion(ctx context.Context, namespace string, name strin if labels == nil { labels = make(map[string]string) } - // Only handle flux reconcile annotation if the app is managed by Flux. - _, fluxLabelExists := labels[k8smetadataLabel.FluxKustomizeName] - if fluxLabelExists { - annotations := accessor.GetAnnotations() - if annotations == nil { - annotations = make(map[string]string) - } - if suspendReconciliation { - annotations[k8smetadataAnnotation.FluxKustomizeReconcile] = "disabled" - state = append(state, fmt.Sprintf("added annotations[\"%s\"]=%s", k8smetadataAnnotation.FluxKustomizeReconcile, "disabled")) - } else { - - _, exists := annotations[k8smetadataAnnotation.FluxKustomizeReconcile] - if exists { - delete(annotations, k8smetadataAnnotation.FluxKustomizeReconcile) - state = append(state, fmt.Sprintf("removed annotations[\"%s\"]", k8smetadataAnnotation.FluxKustomizeReconcile)) - } + annotations := accessor.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + + if suspendReconciliation { + labels[k8smetadataAnnotation.FluxKustomizeReconcile] = "disabled" + state = append(state, fmt.Sprintf(`added annotations["%s"]=%s`, k8smetadataAnnotation.FluxKustomizeReconcile, "disabled")) + } else { + // Only report removal if the annotation or label was actually present + _, hadLabel := labels[k8smetadataAnnotation.FluxKustomizeReconcile] + _, hadAnnotation := annotations[k8smetadataAnnotation.FluxKustomizeReconcile] + + delete(labels, k8smetadataAnnotation.FluxKustomizeReconcile) + delete(annotations, k8smetadataAnnotation.FluxKustomizeReconcile) + + if hadLabel || hadAnnotation { + state = append(state, fmt.Sprintf(`removed annotations["%s"]`, k8smetadataAnnotation.FluxKustomizeReconcile)) } - accessor.SetAnnotations(annotations) } + accessor.SetLabels(labels) + accessor.SetAnnotations(annotations) err = s.client.Patch(ctx, appCR, patch) if err != nil { diff --git a/pkg/data/domain/app/spec.go b/pkg/data/domain/app/spec.go index 7ab734f1c..fc8d8db29 100644 --- a/pkg/data/domain/app/spec.go +++ b/pkg/data/domain/app/spec.go @@ -34,6 +34,20 @@ type PatchOptions struct { Version string } +// CreateOptions are the parameters that the Create method takes. +type CreateOptions struct { + Name string + Namespace string + AppName string + AppNamespace string + AppCatalog string + AppVersion string + ConfigVersion string + DisableForceUpgrade bool + UserConfigMapName string + UserSecretName string +} + type Resource interface { Object() runtime.Object } @@ -42,7 +56,10 @@ type Resource interface { // Using this instead of a regular 'struct' makes mocking the // service in tests much simpler. type Interface interface { + Create(context.Context, CreateOptions) (*applicationv1alpha1.App, error) Get(context.Context, GetOptions) (Resource, error) + GetApp(ctx context.Context, namespace, name string) (*applicationv1alpha1.App, error) + ListApps(ctx context.Context, namespace string) (*applicationv1alpha1.AppList, error) Patch(context.Context, PatchOptions) ([]string, error) } diff --git a/pkg/scheme/scheme.go b/pkg/scheme/scheme.go index 3e662f99b..b0e622c73 100644 --- a/pkg/scheme/scheme.go +++ b/pkg/scheme/scheme.go @@ -1,6 +1,8 @@ package scheme import ( + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" application "github.com/giantswarm/apiextensions-application/api/v1alpha1" gscore "github.com/giantswarm/apiextensions/v6/pkg/apis/core/v1alpha1" infrastructure "github.com/giantswarm/apiextensions/v6/pkg/apis/infrastructure/v1alpha3" @@ -37,6 +39,8 @@ func NewSchemeBuilder() []func(*runtime.Scheme) error { release.AddToScheme, // Release securityv1alpha1.AddToScheme, // Organizations capainfrav1.AddToScheme, // AWSCluster (CAPA) + kustomizev1.AddToScheme, // Flux Kustomization + sourcev1.AddToScheme, // Flux GitRepository, HelmRepository, etc. } }