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 flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

buildGoModule' = pkgs.buildGoModule.override { inherit go; };

deployahVendorHash = "sha256-ixUXF5jnjoL67Mmv8t55QIV3Xv9QyDaFzwr+p0YJ0mM=";
deployahVendorHash = "sha256-CoOzDrJDWuJCBgN1PQoS3k28Mrg78tv4FxvMvZGn+Ms=";

deployah = import ./nix/deployah.nix {
buildGoModule = buildGoModule';
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ require (
github.com/fluxcd/pkg/envsubst v1.7.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gonvenience/ytbx v1.5.0
github.com/google/go-containerregistry v0.21.7
github.com/google/go-containerregistry v0.21.8
github.com/google/renameio/v2 v2.0.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/homeport/dyff v1.12.0
Expand All @@ -30,6 +30,7 @@ require (
k8s.io/apiextensions-apiserver v0.36.3
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
mvdan.cc/sh/v3 v3.13.1
nabat.dev v0.6.3
sigs.k8s.io/e2e-framework v0.7.0
sigs.k8s.io/kind v0.32.0
Expand Down Expand Up @@ -89,7 +90,7 @@ require (
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/docker/cli v29.5.3+incompatible // indirect
github.com/docker/cli v29.6.2+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.8 // indirect
github.com/docker/go-connections v0.8.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
Expand Down
12 changes: 8 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw=
github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q=
github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
Expand Down Expand Up @@ -245,6 +245,8 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
Expand Down Expand Up @@ -295,8 +297,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU=
github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE=
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
Expand Down Expand Up @@ -700,6 +702,8 @@ k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w=
k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
nabat.dev v0.6.3 h1:SFYOSHJTMzJzacHyVzvnEq0IA95+r0PpxQr/aRjM2UY=
nabat.dev v0.6.3/go.mod h1:A1OPeIUHLkDG8BOnO3rfeBubcw0iPJ8GY7MuB/0lBB0=
oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo=
Expand Down
4 changes: 4 additions & 0 deletions internal/action/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,25 @@ var testManifest = &spec.Spec{

// Run loads the manifest and delegates to the deployer.
func TestDeploy_Run(t *testing.T) {
t.Parallel()
t.Run("succeeds when deployer and loader succeed", func(t *testing.T) {
t.Parallel()
d := action.NewDeploy(&mockDeployer{}, &mockSpecLoader{m: testManifest})
m, err := d.Run(t.Context(), "prod", false)
require.NoError(t, err)
assert.Equal(t, "my-app", m.Project)
})

t.Run("returns error when manifest loader fails", func(t *testing.T) {
t.Parallel()
d := action.NewDeploy(&mockDeployer{}, &mockSpecLoader{err: fmt.Errorf("not found")})
_, err := d.Run(t.Context(), "prod", false)
require.Error(t, err)
assert.Contains(t, err.Error(), "load spec")
})

t.Run("returns error when deployer fails", func(t *testing.T) {
t.Parallel()
d := action.NewDeploy(&mockDeployer{err: fmt.Errorf("helm error")}, &mockSpecLoader{m: testManifest})
_, err := d.Run(t.Context(), "prod", false)
require.Error(t, err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,69 @@
// See the License for the specific language governing permissions and
// limitations under the License.

package common
package cmdopts

import (
"context"
"errors"
"fmt"
"strings"
"net"
"slices"
"syscall"

"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"

"deployah.dev/deployah/internal/k8s"
"deployah.dev/deployah/internal/spec"
)

// clusterHintSuffix is appended to connectivity errors so deploy and plan
// share the same recovery guidance.
const clusterHintSuffix = "\n\nHint: the target cluster/context may be unavailable. For a local cluster, run 'deployah cluster up' (and pass --context kind-deployah or set the environment's 'context' field)."

// ClusterHint returns an actionable suffix for errors that look like the
// target cluster or context is missing or unreachable. It returns an empty
// string for unrelated errors. Shared by `deployah deploy` and `deployah
// plan` so their connectivity error messages never drift apart.
func ClusterHint(err error) string {
if err == nil {
if !isClusterUnreachable(err) {
return ""
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "context") && (strings.Contains(msg, "does not exist") || strings.Contains(msg, "not found")),
strings.Contains(msg, "connection refused"),
strings.Contains(msg, "dial tcp"),
strings.Contains(msg, "no configuration has been provided"),
strings.Contains(msg, "couldn't get current server api group list"):
return "\n\nHint: the target cluster/context may be unavailable. For a local cluster, run 'deployah cluster up' (and pass --context kind-deployah or set the environment's 'context' field)."
default:
return ""
return clusterHintSuffix
}

// isClusterUnreachable reports whether err (or a wrapped cause) indicates a
// missing kubeconfig, unknown context, or network failure reaching the API.
func isClusterUnreachable(err error) bool {
if err == nil {
return false
}

// clientcmd.IsEmptyConfig / IsContextNotFound do not walk wrappers, so
// unwrap one level at a time before those checks.
for e := err; e != nil; e = errors.Unwrap(e) {
if clientcmd.IsEmptyConfig(e) || clientcmd.IsContextNotFound(e) {
return true
}
}
if multi, ok := err.(interface{ Unwrap() []error }); ok {
if slices.ContainsFunc(multi.Unwrap(), isClusterUnreachable) {
return true
}
}

if opErr, ok := errors.AsType[*net.OpError](err); ok && opErr != nil {
return true
}
if dnsErr, ok := errors.AsType[*net.DNSError](err); ok && dnsErr != nil {
return true
}
if errno, ok := errors.AsType[syscall.Errno](err); ok &&
(errno == syscall.ECONNREFUSED || errno == syscall.ECONNRESET) {
return true
}
return false
}

// HasExposeComponents reports whether any component in the spec declares an
Expand Down
103 changes: 103 additions & 0 deletions internal/cmd/cmdopts/cluster_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright 2025 The Deployah Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmdopts

import (
"errors"
"fmt"
"net"
"net/url"
"syscall"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/client-go/tools/clientcmd"
)

// TestClusterHint verifies typed connectivity errors get a recovery hint.
func TestClusterHint(t *testing.T) {
t.Parallel()

tests := []struct {
name string
err error
wantHint bool
}{
{name: "nil", err: nil, wantHint: false},
{name: "unrelated", err: errors.New("chart render failed"), wantHint: false},
{name: "empty config", err: clientcmd.ErrEmptyConfig, wantHint: true},
{name: "wrapped empty config", err: fmt.Errorf("rest config: %w", clientcmd.ErrEmptyConfig), wantHint: true},
{name: "no context", err: clientcmd.ErrNoContext, wantHint: true},
{
name: "wrapped no context",
err: fmt.Errorf("target: %w", clientcmd.ErrNoContext),
wantHint: true,
},
{
name: "op error connection refused",
err: &net.OpError{
Op: "dial",
Net: "tcp",
Err: syscall.ECONNREFUSED,
},
wantHint: true,
},
{
name: "wrapped op error",
err: fmt.Errorf("helm client: %w", &net.OpError{
Op: "dial",
Net: "tcp",
Err: syscall.ECONNREFUSED,
}),
wantHint: true,
},
{
name: "url error wrapping dial",
err: &url.Error{
Op: "Get",
URL: "https://127.0.0.1:6443",
Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED},
},
wantHint: true,
},
{
name: "dns error",
err: &net.DNSError{
Err: "no such host",
Name: "kubernetes.default",
},
wantHint: true,
},
{
name: "joined with unreachable",
err: errors.Join(errors.New("other"), syscall.ECONNREFUSED),
wantHint: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ClusterHint(tt.err)
if tt.wantHint {
require.NotEmpty(t, got)
assert.Contains(t, got, "deployah cluster up")
return
}
assert.Empty(t, got)
})
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package common
package cmdopts

import (
"fmt"
Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/common/doc.go → internal/cmd/cmdopts/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.

// Package common defines flags and options shared by every Deployah command.
// Package cmdopts defines flags and helpers shared by every Deployah command.
//
// [GlobalOptions] captures persistent CLI flags such as namespace, kubeconfig,
// spec path, debug mode, and operation timeout. The root command reads
// these values in its pre-run hook and builds a [runtime.Runtime] from them.
package common
// these values in its pre-run hook and builds a [session.Session] from them.
package cmdopts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package common
package cmdopts

import "time"

// GlobalOptions are the persistent flags shared by all commands.
// Used exclusively in the OnPreRun hook to construct the runtime.
// Used exclusively in the OnPreRun hook to construct the session.
type GlobalOptions struct {
Namespace string `nabat:"namespace"`
Kubeconfig string `nabat:"kubeconfig"`
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/delete/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"sigs.k8s.io/yaml"

"deployah.dev/deployah/internal/cli"
"deployah.dev/deployah/internal/cmd/common"
"deployah.dev/deployah/internal/cmd/cmdopts"
"deployah.dev/deployah/internal/helm"
"deployah.dev/deployah/internal/session"
"deployah.dev/deployah/internal/spec"
Expand Down Expand Up @@ -125,7 +125,7 @@ func runDelete(c *nabat.Context) error {
if err != nil {
return fmt.Errorf("target cluster: %w", err)
}
common.WarnContextFallback(c, cluster, opts.Environment)
cmdopts.WarnContextFallback(c, cluster, opts.Environment)
helmClient, err := cluster.Helm()
if err != nil {
return fmt.Errorf("helm client: %w", err)
Expand Down
Loading