Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1fe3ad7
fzf with bubbletea
TheoBrigitte Dec 5, 2025
43326c4
with fuzzy finding
TheoBrigitte Dec 5, 2025
103ce60
fmt
TheoBrigitte Dec 5, 2025
7cd9a48
Add new deploy command
TheoBrigitte Dec 5, 2025
62ec649
Implement app create and update
TheoBrigitte Dec 5, 2025
4b86e5f
Move logic to domain package
TheoBrigitte Dec 5, 2025
726c143
Implement logic from deploy.sh
TheoBrigitte Dec 5, 2025
e0f02a9
Add bubbletea UI
TheoBrigitte Dec 5, 2025
939a446
Add --list
TheoBrigitte Dec 5, 2025
fa214c7
Add deploy UI tests
TheoBrigitte Dec 5, 2025
447734c
Improve status output
TheoBrigitte Dec 5, 2025
1efa6a9
Refactor and simplify
TheoBrigitte Dec 5, 2025
ebbcab8
Add interactive mode
TheoBrigitte Dec 5, 2025
a608a2e
Add --undeploy-on-exit to restore state
TheoBrigitte Dec 5, 2025
8643a65
Add --sync flag, and improve restore state
TheoBrigitte Dec 6, 2025
879f53c
Update CHANGELOG
TheoBrigitte Dec 6, 2025
40edbc5
adjust usage
TheoBrigitte Dec 6, 2025
f73ddff
List available apps from the catalog along with installed apps
TheoBrigitte Dec 9, 2025
fe2768e
Fix app deployment with sync
TheoBrigitte Dec 9, 2025
c4f65dd
Add sync and interactive modes to undeploy
TheoBrigitte Dec 9, 2025
57b1521
Fix table column alignement
TheoBrigitte Dec 9, 2025
dfecdbf
Less empty lines on output
TheoBrigitte Dec 9, 2025
77ad138
Make --sync and --undeploy-on-exit the defaults
TheoBrigitte Dec 9, 2025
9f5547b
Show resource kind
TheoBrigitte Dec 9, 2025
a63f84d
Add versions listing for config
TheoBrigitte Dec 9, 2025
ea704dc
Add interactive mode to deploy config
TheoBrigitte Dec 9, 2025
626d141
Change versions listing format for config
TheoBrigitte Dec 9, 2025
b13ee6b
Show all config versions when deploying interactively
TheoBrigitte Dec 9, 2025
c6a997f
Adjust prompt for spaces and >
TheoBrigitte Dec 9, 2025
82b003d
Remove empty line
TheoBrigitte Dec 9, 2025
4020c7f
sort imports
TheoBrigitte Dec 10, 2025
d957ad7
Merge remote-tracking branch 'origin/main' into deploy-command
TheoBrigitte Dec 10, 2025
fdad8be
Fix shadowed namespace flag, and keep default values
TheoBrigitte Dec 10, 2025
be4763a
Handle or ignore fmt.Fprint errors
TheoBrigitte Jan 5, 2026
c2f1cf3
Improve error handling
TheoBrigitte Jan 5, 2026
897934a
Remove deadcode, fix catalog entries sorting
TheoBrigitte Jan 5, 2026
86aebb9
Add --name flag to override app name
TheoBrigitte Jan 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
339 changes: 339 additions & 0 deletions cmd/deploy/app.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading