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
2 changes: 1 addition & 1 deletion cmd/app/.install.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ openframe app install --non-interactive

# Deploy a specific branch or release tag
openframe app install --ref develop
openframe app install --ref v1.2.3
openframe app install --ref 1.0.48

# Target an explicit kube-context (scriptable)
openframe app install --context my-context
Expand Down
1 change: 1 addition & 0 deletions cmd/app/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ password, and the command to open the ArgoCD UI locally.
Examples:
openframe app access
openframe app access --context k3d-openframe-dev`,
Args: noPositionalArgs,
RunE: runAccessCommand,
Annotations: map[string]string{"readonly": "true"},
}
Expand Down
15 changes: 15 additions & 0 deletions cmd/app/app.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
package app

import (
"fmt"
"strings"

"github.com/flamingo-stack/openframe-cli/internal/shared/ui"
"github.com/spf13/cobra"
)

// noPositionalArgs rejects stray positional arguments loudly, naming the flag
// that does the targeting. Without it cobra defaults to ArbitraryArgs, and
// `openframe app status my-eks` silently ran against whatever the current
// kube-context happened to be — for uninstall, destructively so.
func noPositionalArgs(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return fmt.Errorf("%q takes no positional arguments (got %q) — use --context to target a cluster",
cmd.CommandPath(), strings.Join(args, " "))
}
return nil
Comment thread
oleg-flamingocx marked this conversation as resolved.
}

// GetAppCmd returns the app command and its subcommands.
func GetAppCmd() *cobra.Command {
cmd := &cobra.Command{
Expand Down
7 changes: 5 additions & 2 deletions cmd/app/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Examples:
openframe app install my-cluster # Install on specific cluster
openframe app install --non-interactive # Use existing openframe-helm-values.yaml (CI/CD)
openframe app install --ref develop # Deploy a branch
openframe app install --ref v1.2.3 # Deploy a release tag`, argocd.ArgoCDChartVersion),
openframe app install --ref 1.0.48 # Deploy a release tag`, argocd.ArgoCDChartVersion),
RunE: runInstallCommand,
SilenceErrors: true, // Errors are handled by our custom error handler
SilenceUsage: true, // Don't show usage on errors
Expand Down Expand Up @@ -218,7 +218,10 @@ func addInstallFlags(cmd *cobra.Command) {
cmd.Flags().BoolP("force", "f", false, "Force installation even if charts already exist")
cmd.Flags().Bool("dry-run", false, "Show what would be installed without executing")
cmd.Flags().String("github-repo", chartmodels.RepoOSSTenant, "GitHub repository URL")
cmd.Flags().StringP("ref", "r", "", "Git ref (branch or release tag, e.g. v1.2.3) to deploy")
// The example must match the chart repository's actual tag scheme (1.0.48,
// no v prefix) — a help text advertising v1.2.3 produced exactly the failing
// `--ref v…` invocations it seemed to endorse.
cmd.Flags().StringP("ref", "r", "", "Git ref (branch or release tag, e.g. 1.0.48) to deploy")
cmd.Flags().String("cert-dir", "", "Certificate directory (auto-detected if not provided)")
cmd.Flags().Bool("non-interactive", false, "Skip all prompts, use existing openframe-helm-values.yaml")
cmd.Flags().StringP("context", "c", "", "Kube-context to install into (skips interactive selection)")
Expand Down
1 change: 1 addition & 0 deletions cmd/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ sync/health, summarizes overall readiness, and prints how to sign in.
Examples:
openframe app status
openframe app status --context k3d-openframe-dev`,
Args: noPositionalArgs,
RunE: runStatusCommand,
Annotations: map[string]string{"readonly": "true"},
}
Expand Down
27 changes: 27 additions & 0 deletions cmd/app/status_access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,30 @@ func TestStatusCommand_WatchRejectsMachineOutput(t *testing.T) {
t.Fatalf("expected the watch/output conflict error, got %v", err)
}
}

// A stray positional argument must fail loudly, not be silently discarded:
// `openframe app status my-eks` used to run against whatever the current
// kube-context was — masked in the linear create→install flow, wrong (and for
// uninstall destructive) the moment the context had moved elsewhere.
func TestContextTargetedCommands_RejectPositionalArgs(t *testing.T) {
for _, build := range []func() *cobra.Command{getStatusCmd, getAccessCmd, getUninstallCmd} {
cmd := build()
t.Run(cmd.Name(), func(t *testing.T) {
if cmd.Args == nil {
t.Fatal("Args validator missing — cobra defaults to ArbitraryArgs and a cluster name is silently ignored")
}
err := cmd.Args(cmd, []string{"my-eks"})
if err == nil {
t.Fatal("a positional cluster name must be rejected")
}
for _, want := range []string{"my-eks", "--context"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q must mention %q", err.Error(), want)
}
}
if err := cmd.Args(cmd, nil); err != nil {
t.Fatalf("no positional args must stay valid, got %v", err)
}
})
}
}
1 change: 1 addition & 0 deletions cmd/app/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Examples:
openframe app uninstall
openframe app uninstall --context k3d-openframe-dev
openframe app uninstall --yes --delete-namespace`,
Args: noPositionalArgs,
RunE: runUninstallCommand,
}
cmd.Flags().StringP("context", "c", "", "Kube-context to use (defaults to the current context)")
Expand Down
10 changes: 8 additions & 2 deletions docs/getting-started/cloud-clusters.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,14 @@ minutes; the CLI streams per-resource progress. GKE nodes are private (no
external IPs, egress via Cloud NAT) with a public control-plane endpoint, so
the flow works in organizations enforcing `restrict_vm_external_ips`. EKS
clusters get a dedicated VPC (2 AZs, nodes in private subnets behind a single
NAT gateway) and the `aws-ebs-csi-driver` addon, so PersistentVolumeClaims
work out of the box.
NAT gateway), the core addons (`vpc-cni`, `kube-proxy`, `coredns`) and the
`aws-ebs-csi-driver` addon with a default gp3 StorageClass, so networking and
PersistentVolumeClaims work out of the box. The generated Terraform pins the
upstream EKS/VPC modules to exact versions — upstream default changes arrive
only with a deliberate CLI release, never mid-`create`. The default node type
is `m7i-flex.large`, which is Free-Tier-eligible: a brand-new AWS account (its
Free plan refuses non-eligible instance types) can run the documented flow
unchanged.
When it finishes, your kubeconfig gets a context named after the cluster and
it becomes the current context — `kubectl get nodes` just works
(authentication runs through short-lived tokens via `aws eks get-token` /
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ openframe cluster create --type k3d --nodes 1 --skip-wizard
openframe app install # Interactive context picker
openframe app install -c k3d-openframe-dev # Explicit context
openframe app install --non-interactive # CI (reuse existing values file)
openframe app install --ref v1.2.3 # Deploy specific tag
openframe app install --ref 1.0.48 # Deploy specific tag
openframe app install --dry-run # Preview only
```

Expand Down
10 changes: 8 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ module github.com/flamingo-stack/openframe-cli

go 1.26.0

// 1.26.0/1.26.1 crash at random on windows-amd64 (return addresses corrupted
// during GC stack scanning — golang/go#77975); the fix shipped in 1.26.2.
// CI and releases resolve the toolchain from this file, so this line is what
// keeps the shipped Windows binaries off the buggy runtimes.
toolchain go1.26.5

require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0
Expand Down Expand Up @@ -54,7 +60,7 @@ require (
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.4 // indirect
github.com/cloudflare/circl v1.6.5 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
github.com/cyphar/filepath-securejoin v0.7.0 // indirect
Expand Down Expand Up @@ -96,7 +102,7 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/certificate-transparency-go v1.3.3 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-containerregistry v0.21.8 // indirect
github.com/google/go-containerregistry v0.21.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/color v1.6.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
Expand Down Expand Up @@ -270,8 +270,8 @@ github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4y
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.21.8 h1:Ig/zIsnztdCUNaiNNczE+MoP5xcyUMfvpvfOr1xyMLE=
github.com/google/go-containerregistry v0.21.8/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo=
github.com/google/go-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/216WGQq2dokuLs=
github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
Expand Down
42 changes: 42 additions & 0 deletions internal/chart/providers/git/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import (
"strings"

"github.com/flamingo-stack/openframe-cli/internal/chart/models"
sharedErrors "github.com/flamingo-stack/openframe-cli/internal/shared/errors"
gogit "github.com/go-git/go-git/v5"
gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pterm/pterm"
)

Expand Down Expand Up @@ -75,6 +78,45 @@ func (r *Repository) CloneChartRepository(ctx context.Context, config *models.Ap
return nil, fmt.Errorf("failed to clone repository: %s", maskToken(lastErr.Error(), auth.token))
}

// ValidateRef checks — in one ls-remote round-trip, before anything touches
// the cluster — that the configured ref exists in the chart repository as a
// branch or tag. The clone used to be the first place a bad --ref surfaced,
// AFTER ArgoCD was already installed: certificates refreshed, helm release
// deployed, API port awaited, then "branch does not exist" — leaving the
// cluster mutated with no applications. A missing ref returns a
// *sharedErrors.BranchNotFoundError carrying the refs the repository DOES
// offer, so the error can answer the next question instead of prompting it.
func (r *Repository) ValidateRef(ctx context.Context, config *models.AppOfAppsConfig) error {
auth := extractGitAuth(config.GitHubRepo)
remote := gogit.NewRemote(memory.NewStorage(), &gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{auth.cleanURL},
})
refs, err := remote.ListContext(ctx, &gogit.ListOptions{Auth: auth.buildAuth()})
if err != nil {
// Same transport and auth as the clone: whatever broke here would have
// broken the clone too, only after the cluster had been mutated.
return fmt.Errorf("could not list refs of the chart repository: %s", maskToken(err.Error(), auth.token))
}

var branches, tags []string
for _, ref := range refs {
name := ref.Name()
switch {
case name.IsBranch():
branches = append(branches, name.Short())
case name.IsTag():
tags = append(tags, name.Short())
}
}
for _, existing := range append(branches, tags...) {
if existing == config.GitHubBranch {
return nil
}
}
return sharedErrors.NewBranchNotFoundErrorWithRefs(config.GitHubBranch, branches, tags)
}

// chartResult validates that chartPath exists inside the freshly cloned tempDir
// and returns the CloneResult, cleaning up on failure.
func (r *Repository) chartResult(tempDir, chartSubPath string) (*CloneResult, error) {
Expand Down
34 changes: 34 additions & 0 deletions internal/chart/providers/git/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/flamingo-stack/openframe-cli/internal/chart/models"
sharedErrors "github.com/flamingo-stack/openframe-cli/internal/shared/errors"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -148,3 +149,36 @@ func ofcredCount(t *testing.T) int {
require.NoError(t, err)
return len(matches)
}

// ValidateRef is the pre-install ls-remote preflight: a bad --ref must be
// caught in one round-trip BEFORE ArgoCD is installed onto the cluster.
func TestValidateRef_BranchAndTag(t *testing.T) {
url, branch := makeLocalRepo(t, "manifests/app-of-apps")
tagLocalRepo(t, url, "1.0.48")
repo := NewRepository()

for _, ref := range []string{branch, "1.0.48"} {
require.NoError(t, repo.ValidateRef(context.Background(), &models.AppOfAppsConfig{
GitHubRepo: url,
GitHubBranch: ref,
}), "existing ref %q must validate", ref)
}
}

func TestValidateRef_MissingRefListsAvailable(t *testing.T) {
url, branch := makeLocalRepo(t, "manifests/app-of-apps")
tagLocalRepo(t, url, "1.0.48")
repo := NewRepository()

err := repo.ValidateRef(context.Background(), &models.AppOfAppsConfig{
GitHubRepo: url,
GitHubBranch: "v1.4.0",
})
require.Error(t, err)

var bnfErr *sharedErrors.BranchNotFoundError
require.ErrorAs(t, err, &bnfErr, "a missing ref must be a BranchNotFoundError so the handler renders it")
require.Equal(t, "v1.4.0", bnfErr.Branch)
require.Contains(t, bnfErr.Branches, branch, "the error must carry the refs the repository DOES offer")
require.Contains(t, bnfErr.Tags, "1.0.48")
}
17 changes: 14 additions & 3 deletions internal/chart/services/chart_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,19 @@ type ChartService struct {
// to exercise the install orchestration without a cluster (see
// install_orchestration_test.go). Kept unexported on purpose — the public
// constructors' signatures and behavior are unchanged.
newFileCleanup func() installFileCleanup
installServices installServicesFactory
installRetryPolicy sharedErrors.RetryPolicy
newFileCleanup func() installFileCleanup
installServices installServicesFactory
installRetryPolicy sharedErrors.RetryPolicy
installRefValidator types.GitRefValidator
}

// installRefValidatorOrDefault returns the injected --ref preflight, or the
// production git repository provider.
func (cs *ChartService) installRefValidatorOrDefault() types.GitRefValidator {
if cs.installRefValidator != nil {
return cs.installRefValidator
}
return cs.gitRepository
}

// fileCleanupOrDefault returns the injected file-cleanup factory's product, or
Expand Down Expand Up @@ -573,6 +583,7 @@ func (w *InstallationWorkflow) performInstallation(ctx context.Context, config c
installer := &Installer{
argoCDService: argoCDService,
appOfAppsService: appOfAppsService,
refValidator: w.chartService.installRefValidatorOrDefault(),
}

err = installer.InstallChartsWithContext(ctx, config)
Expand Down
Loading
Loading