Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -325,17 +325,17 @@ jobs:
WORK="$(mktemp -d)"; export HOME="$WORK/home"; mkdir -p "$HOME"
go build -ldflags "-X github.com/flamingo-stack/openframe-cli/cmd.version=0.0.1" -o "$WORK/openframe" .
OF="$WORK/openframe"
[ "$("$OF" --version | cut -d' ' -f1)" = "0.0.1" ] || { echo "::error::ldflags version injection broken"; exit 1; }
[ "$("$OF" --version | head -n1 | cut -d' ' -f1)" = "0.0.1" ] || { echo "::error::ldflags version injection broken"; exit 1; }

echo "--- update to the latest release (verifies the cosign bundle)"
"$OF" update --yes
got="$("$OF" --version | cut -d' ' -f1)"
got="$("$OF" --version | head -n1 | cut -d' ' -f1)"
[ "$got" = "$LATEST" ] || { echo "::error::after update --version is $got, want $LATEST"; exit 1; }
echo "updated 0.0.1 -> $got"

echo "--- rollback restores the previous binary (offline)"
"$OF" update rollback --yes
back="$("$OF" --version | cut -d' ' -f1)"
back="$("$OF" --version | head -n1 | cut -d' ' -f1)"
[ "$back" = "0.0.1" ] || { echo "::error::after rollback --version is $back, want 0.0.1"; exit 1; }

echo "--- rollback again: nothing left to restore, clean exit"
Expand All @@ -346,7 +346,7 @@ jobs:
W2="$(mktemp -d)"; HOME="$W2/home"; mkdir -p "$HOME"
go build -ldflags "-X github.com/flamingo-stack/openframe-cli/cmd.version=0.0.1" -o "$W2/openframe" .
HOME="$W2/home" "$W2/openframe" update "$spelling" --yes
v="$(HOME="$W2/home" "$W2/openframe" --version | cut -d' ' -f1)"
v="$(HOME="$W2/home" "$W2/openframe" --version | head -n1 | cut -d' ' -f1)"
[ "$v" = "$LATEST" ] || { echo "::error::update $spelling landed on $v, want $LATEST"; exit 1; }
echo "OK: update $spelling -> $v"
done
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ OpenFrame CLI is part of the broader [OpenFrame](https://openframe.ai) ecosystem
- **Lifecycle operations**: Create, delete, list, and monitor Kubernetes clusters
- **K3D integration**: Lightweight Kubernetes for development and testing
- **Status monitoring**: Real-time cluster health and resource monitoring
- **Easy cleanup**: Remove clusters and associated resources with simple commands
- **Easy teardown**: `cluster delete` removes a cluster and its resources; `cluster cleanup` reclaims disk by pruning unused node images

### 📦 Chart & Application Management
- **Helm chart installation**: Streamlined chart deployment with dependency management
Expand Down
30 changes: 7 additions & 23 deletions cmd/cluster/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@ package cluster
import (
"fmt"

"github.com/flamingo-stack/openframe-cli/internal/chart/providers/argocd"
"github.com/flamingo-stack/openframe-cli/internal/cluster/models"
"github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites"
"github.com/flamingo-stack/openframe-cli/internal/cluster/ui"
"github.com/flamingo-stack/openframe-cli/internal/cluster/utils"
"github.com/flamingo-stack/openframe-cli/internal/shared/executor"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)

Expand All @@ -19,11 +16,13 @@ func getCleanupCmd() *cobra.Command {

cleanupCmd := &cobra.Command{
Use: "cleanup [NAME]",
Short: "Clean up unused cluster resources",
Long: `Remove unused images and resources from cluster nodes.
Short: "Prune unused container images from cluster nodes",
Long: `Reclaim disk space by pruning unused container images inside each cluster node.

Cleans up Docker images and resources, freeing disk space.
Useful for development clusters with many builds.
Only images that no container references are removed. Installed applications,
Helm releases and namespaces are never touched. To remove the OpenFrame
platform use 'openframe app uninstall'; to remove the whole cluster use
'openframe cluster delete'.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Examples:
openframe cluster cleanup
Expand Down Expand Up @@ -90,24 +89,9 @@ func runCleanupCluster(cmd *cobra.Command, args []string) error {
}
}

// Inject the ArgoCD-backed application cleaner (composition root: only the
// command layer may import both the cluster and the chart subsystems).
// Without it, cleanup skips the Application delete/finalizer-strip phases and
// the argocd namespace can stay stuck in Terminating. Best-effort: a cluster
// that is unreachable or has no ArgoCD simply cleans up without it.
if cfg, cerr := service.GetRestConfig(clusterName); cerr == nil {
if mgr, merr := argocd.NewManagerWithConfig(executor.NewRealCommandExecutor(false, globalFlags.Global.Verbose), cfg); merr == nil {
service = service.WithApplicationCleaner(mgr)
} else if globalFlags.Global.Verbose {
pterm.Warning.Printf("ArgoCD cleanup unavailable: %v\n", merr)
}
} else if globalFlags.Global.Verbose {
pterm.Warning.Printf("Cluster not reachable for ArgoCD cleanup: %v\n", cerr)
}

// Execute cluster cleanup through service layer. A nil error with failed
// phases is a partial cleanup: the summary names what was left behind.
result, err := service.CleanupCluster(cmd.Context(), clusterName, clusterType, utils.GetGlobalFlags().Global.Verbose, utils.GetGlobalFlags().Cleanup.Force)
result, err := service.CleanupCluster(cmd.Context(), clusterName, clusterType, utils.GetGlobalFlags().Global.Verbose)
if err != nil {
operationsUI.ShowOperationError("cleanup", clusterName, err)
return err
Expand Down
2 changes: 1 addition & 1 deletion cmd/cluster/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ This command group provides cluster lifecycle management functionality:
• list - Show all managed clusters
• status - Display detailed cluster information
• use - Switch the kubectl context to a cluster
• cleanup - Remove unused images and resources
• cleanup - Prune unused container images from cluster nodes

Supports K3d clusters for local development and Google GKE / AWS EKS for cloud deployments.

Expand Down
75 changes: 59 additions & 16 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import (
"fmt"
"os"
"os/signal"
"runtime"
"runtime/debug"
"strings"
"syscall"

"github.com/flamingo-stack/openframe-cli/cmd/app"
"github.com/flamingo-stack/openframe-cli/cmd/bootstrap"
"github.com/flamingo-stack/openframe-cli/cmd/cluster"
"github.com/flamingo-stack/openframe-cli/cmd/prerequisites"
"github.com/flamingo-stack/openframe-cli/cmd/update"
"github.com/flamingo-stack/openframe-cli/internal/chart/providers/argocd"
"github.com/flamingo-stack/openframe-cli/internal/shared/config"
"github.com/flamingo-stack/openframe-cli/internal/shared/download"
"github.com/flamingo-stack/openframe-cli/internal/shared/selfupdate"
Expand Down Expand Up @@ -96,26 +99,66 @@ func GetRootCmd(versionInfo VersionInfo) *cobra.Command {
return buildRootCommand(versionInfo)
}

// pinnedDependencies renders the versions this build installs (verified,
// checksum-pinned downloads) and deploys — so `--version` answers not just
// "which CLI" but "which terraform/helm/argocd comes with it". Sources: the
// PinnedTool definitions in internal/shared/download and the ArgoCD chart pin
// in internal/chart/providers/argocd.
func pinnedDependencies() string {
var b strings.Builder
b.WriteString("Pinned dependencies (installed verified at exactly these versions):\n")
for _, dep := range []struct{ name, version string }{
{"terraform", download.Terraform.Version},
{"helm", download.Helm.Version},
{"k3d", download.K3d.Version},
{"mkcert", download.Mkcert.Version},
{"infracost", download.Infracost.Version + " (optional, cost estimates)"},
{"argo-cd", "chart " + argocd.ArgoCDChartVersion},
} {
fmt.Fprintf(&b, " %-10s %s\n", dep.name, dep.version)
}
return strings.TrimRight(b.String(), "\n")
}

// buildRootCommand constructs the root command with given version info
func buildRootCommand(versionInfo VersionInfo) *cobra.Command {
rootCmd := &cobra.Command{
Use: "openframe",
Short: "OpenFrame CLI - Kubernetes cluster bootstrapping and chart deployment",
Long: `OpenFrame CLI - Interactive Kubernetes Platform Bootstrapper

OpenFrame CLI replaces the shell scripts with a modern, interactive terminal UI
for managing OpenFrame Kubernetes deployments. Built following best practices
for CLI design with wizard-style interactive prompts.

Key Features:
- Interactive Wizard - Step-by-step guided setup
- Cluster Management - local K3d and cloud GKE / AWS EKS clusters
- Helm Integration - App-of-Apps pattern with ArgoCD
- Prerequisite Checking - Validates tools before running

The CLI provides both interactive modes for new users and flag-based
operation for automation and power users.`,
Version: fmt.Sprintf("%s (%s) built on %s", versionInfo.Version, versionInfo.Commit, versionInfo.Date),
Short: "OpenFrame CLI - provision Kubernetes clusters and deploy the OpenFrame platform",
Long: `OpenFrame CLI - Kubernetes Platform Bootstrapper

Provision a Kubernetes cluster — local k3d for development, or cloud GKE/EKS
via Terraform — install the OpenFrame platform onto it (ArgoCD app-of-apps),
and manage the full lifecycle: prerequisites, status, upgrades, teardown.

Typical flows:
openframe bootstrap # local: k3d cluster + platform in one step
openframe cluster create --type gke # cloud: plan, confirm, provision...
openframe app install # ...then install the platform onto it

Command groups:
cluster create, delete, list, status, use, cleanup (prune node images)
app install, upgrade, status, access, uninstall
bootstrap cluster create + app install in one step
prerequisites check and install required tools (--type k3d|eks|gke)
update update this CLI to a newer release

Every command runs interactively by default (wizards, confirmations) and
non-interactively with flags for CI and automation. Cloud creates show a full
terraform plan (and an infracost estimate, when installed) before anything is
applied; cloud deletes require typed confirmation, destroy the
terraform-managed resources, and report any leftovers they could not remove.`,
// The version MUST stay the first whitespace token: selfupdate's
// rollback labels the saved binary by parsing `--version` output that
// way (binaryVersion in internal/shared/selfupdate). The toolchain and
// platform ride along because they are the first questions of any bug
// report about a downloaded release; the pinned-dependency block below
// them answers the second ("which terraform/helm/argocd does this build
// install?") without digging through the source.
Version: fmt.Sprintf("%s (%s) built on %s — %s %s/%s\n\n%s",
versionInfo.Version, versionInfo.Commit, versionInfo.Date,
runtime.Version(), runtime.GOOS, runtime.GOARCH,
pinnedDependencies()),
// Silence errors and usage globally - we handle our own error display
SilenceErrors: true,
SilenceUsage: true,
Expand Down
35 changes: 31 additions & 4 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"testing"

"github.com/pterm/pterm"

"github.com/flamingo-stack/openframe-cli/internal/chart/providers/argocd"
"github.com/flamingo-stack/openframe-cli/internal/shared/config"
"github.com/flamingo-stack/openframe-cli/internal/shared/download"
"github.com/flamingo-stack/openframe-cli/internal/shared/ui"
"github.com/flamingo-stack/openframe-cli/tests/testutil"
)
Expand All @@ -31,7 +34,7 @@ func TestRootCommand(t *testing.T) {
t.Errorf("expected Use to be 'openframe', got %q", cmd.Use)
}

expectedShort := "OpenFrame CLI - Kubernetes cluster bootstrapping and chart deployment"
expectedShort := "OpenFrame CLI - provision Kubernetes clusters and deploy the OpenFrame platform"
if cmd.Short != expectedShort {
t.Errorf("expected Short to be %q, got %q", expectedShort, cmd.Short)
}
Expand Down Expand Up @@ -111,9 +114,33 @@ func TestGetRootCmd(t *testing.T) {
t.Error("Short description should not be empty")
}

expectedVersion := "test-version (test-commit) built on test-date"
if cmd.Version != expectedVersion {
t.Errorf("expected version %q, got %q", expectedVersion, cmd.Version)
// The version must stay the FIRST whitespace token — selfupdate's rollback
// labels the saved binary by parsing `--version` output that way — followed
// by the commit/date and the toolchain/platform suffix.
expectedPrefix := "test-version (test-commit) built on test-date — "
if !strings.HasPrefix(cmd.Version, expectedPrefix) {
t.Errorf("expected version to start with %q, got %q", expectedPrefix, cmd.Version)
}
if !strings.Contains(cmd.Version, runtime.GOOS+"/"+runtime.GOARCH) {
t.Errorf("expected version to name the platform, got %q", cmd.Version)
}
// The pinned-dependency block: --version must answer "which
// terraform/helm/argocd does this build install" from the single sources
// of truth (download pins, argocd chart pin), never hardcoded copies.
if !strings.Contains(cmd.Version, runtime.Version()) {
t.Errorf("expected version to name the Go toolchain, got %q", cmd.Version)
}
for _, dep := range []string{
"terraform " + download.Terraform.Version,
"helm " + download.Helm.Version,
"k3d " + download.K3d.Version,
"mkcert " + download.Mkcert.Version,
"infracost " + download.Infracost.Version,
"argo-cd chart " + argocd.ArgoCDChartVersion,
} {
if !strings.Contains(cmd.Version, dep) {
t.Errorf("expected version output to list pinned dependency %q, got:\n%s", dep, cmd.Version)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down
6 changes: 4 additions & 2 deletions docs/architecture/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ The cluster is always a local k3d cluster.

- `openframe cluster create|delete|list|status|cleanup` — cluster lifecycle.
`create` **only creates the cluster**; it never installs the app. (Verb is
`create`; there is no `apply`.) `cleanup` removes unused cluster resources.
`create`; there is no `apply`.) `cleanup` only prunes unused container images
on the nodes; removing the platform is `app uninstall`'s job.
- `openframe app install|upgrade|status|access|uninstall` — installs and operates
the OpenFrame app on an existing, online cluster. `upgrade` re-deploys the
app-of-apps at a new git ref (`--ref`) or forces an ArgoCD hard refresh + sync
Expand Down Expand Up @@ -136,7 +137,8 @@ terraform, that is a different BUSL use profile and needs its own review.
## D8 — Local terraform state in per-cluster workspaces

Each cloud cluster owns a workspace under `~/.openframe/clusters/<name>/`:
the generated root module, `terraform.tfvars.json`, local state, and a
the generated root module, `terraform.tfvars.json`, local state, a
`terraform.log` every apply/destroy appends its output stream to, and a
`cluster.json` registry record (type, status, endpoint/CA). The registry is
what makes cloud clusters visible to `list`/`status`/`delete` without cloud
API calls, and the state file is the only pointer to billed resources — so a
Expand Down
28 changes: 20 additions & 8 deletions docs/getting-started/cloud-clusters.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,11 @@ openframe cluster create my-gke --type gke --project my-project --region us-cent

Useful flags: `--machine-type`, `--min-nodes` / `--max-nodes` (autoscaler
bounds; defaults 1 / 4, must be at least 1 — an explicit 0 is rejected),
`--spot`, `--profile` (AWS), `--nodes` (initial size), `--version`
(`<major>.<minor>`, e.g. `1.33`).
`--spot` (spot-capacity nodes, typically 60–90% off the node cost — the cost
warning suggests it for test clusters), `--profile` (AWS), `--nodes` (initial
size), `--version` (`<major>.<minor>`, e.g. `1.33`), `--ha` (GKE: regional
control plane and nodes; the node count is then **per zone**, and every
summary shows the `N per zone × 3 zones` math).

In interactive sessions the CLI first shows the full Terraform plan and asks
for approval (the `terraform apply` shape; what you approve is exactly what
Expand Down Expand Up @@ -118,9 +121,11 @@ throwaway directory.
## Where the state lives

Each cloud cluster owns a workspace in `~/.openframe/clusters/<name>/`: the
generated Terraform module and the state file. The state is the only pointer
to your billed cloud resources — the workspace is never deleted on a failed
create, only after a successful delete.
generated Terraform module, the state file, and a `terraform.log` that every
apply/destroy appends its full output stream to (so a long operation leaves a
record beyond the terminal). The state is the only pointer to your billed
cloud resources — the workspace is never deleted on a failed create, only
after a successful delete.

- **A create failed or was interrupted?** Re-run the same `cluster create` —
it resumes where it stopped.
Expand All @@ -143,8 +148,12 @@ openframe app install # install OpenFrame onto the current conte
credentials via gcloud when the kubeconfig has no entry yet, and activates
the gcloud configuration matching the cluster's project.

`cluster delete --force` skips the typed confirmation (for CI). `cluster
cleanup` does not apply to cloud clusters — use `delete`.
`cluster delete` tears down more than the terraform state: application
namespaces are removed first so PVC-backed disks/volumes are reclaimed while
the nodes still run, and anything that survives the destroy is swept up
afterwards — listed and deleted with your consent. `--force` skips the typed
confirmation and consents to that sweep (for CI). `cluster cleanup` does not
apply to cloud clusters — use `delete`.

## Troubleshooting

Expand All @@ -160,4 +169,7 @@ cleanup` does not apply to cloud clusters — use `delete`.
`cluster create <name>` to resume, or `cluster delete <name>` to tear down
what was partially created.
- **Verbose Terraform output** — add `--verbose` to stream Terraform's own
logs during create/delete.
logs during create/delete. Either way, the full stream of every
apply/destroy is appended to
`~/.openframe/clusters/<name>/terraform/terraform.log`, and a failed
operation names that path.
2 changes: 1 addition & 1 deletion docs/getting-started/first-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ openframe cluster create my-second-cluster
# Delete a cluster
openframe cluster delete my-second-cluster

# Clean up leftover resources from a failed cluster
# Reclaim disk space by pruning unused container images on cluster nodes
openframe cluster cleanup
```

Expand Down
Loading
Loading