Skip to content

Commit df8ff5f

Browse files
committed
fix(k8s): make KubectlOptions marshallable when RestConfig is set
rest.Config holds func-typed fields (WrapTransport, Dial, Proxy) and encoding/json rejects a func field whether or not it is set, so any marshal of options built by the public NewKubectlOptionsWithRestConfig constructor failed with "unsupported type: transport.WrapperFunc". SaveKubectlOptions turns that into a t.Fatalf, so the test dies. Tag RestConfig json:"-" so options marshal, and have SaveKubectlOptions reject a non-nil RestConfig outright rather than dropping it. Silently dropping it would let LoadKubectlOptions return options that fall back to the ambient kubeconfig and authenticate against a different cluster than the one under test. Serializing a reduced projection of rest.Config was considered and rejected: it holds interfaces and exec credential plugin wiring that cannot be rebuilt, and the JSON-safe fields are the credentials themselves, which should not be written to .test-data. Also drops the staticcheck half of a nolint in kubectl_options_test.go that described this failure as pre-existing.
1 parent dff3438 commit df8ff5f

4 files changed

Lines changed: 154 additions & 12 deletions

File tree

modules/k8s/kubectl_options.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,17 @@ type KubectlOptions struct {
3535
// functions, and only after the node's own ExternalIP has been checked, so most callers can leave it nil.
3636
// It is skipped when serializing options, since a function cannot be represented as JSON.
3737
NodePublicIPLookup NodePublicIPLookup `json:"-"`
38-
RestConfig *rest.Config
39-
Logger *logger.Logger
40-
ContextName string
41-
ConfigPath string
42-
Namespace string
43-
RequestTimeout time.Duration
44-
InClusterAuth bool
38+
// RestConfig is skipped when serializing options. rest.Config holds func-typed fields (WrapTransport, Dial,
39+
// Proxy) and encoding/json rejects a func field whether or not it is set, so without this tag any marshal of
40+
// KubectlOptions carrying a RestConfig fails. Note that SaveKubectlOptions refuses to save such options rather
41+
// than dropping the config silently, since a reload would then authenticate against a different cluster.
42+
RestConfig *rest.Config `json:"-"`
43+
Logger *logger.Logger
44+
ContextName string
45+
ConfigPath string
46+
Namespace string
47+
RequestTimeout time.Duration
48+
InClusterAuth bool
4549
}
4650

4751
// NewKubectlOptions will return a pointer to new instance of KubectlOptions with the configured options

modules/k8s/kubectl_options_test.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package k8s_test
33
import (
44
"context"
55
"encoding/json"
6+
"net/http"
67
"testing"
78

89
gotesting "github.com/gruntwork-io/terratest/modules/core/v2/testing"
910
"github.com/gruntwork-io/terratest/modules/k8s/v2"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
13+
"k8s.io/client-go/rest"
1214
)
1315

1416
// TestKubectlOptionsMarshalsWithLookupSet guards the json:"-" tag on NodePublicIPLookup. KubectlOptions is
@@ -22,11 +24,7 @@ func TestKubectlOptionsMarshalsWithLookupSet(t *testing.T) {
2224
return nil, nil
2325
}
2426

25-
// nolint below: musttag because KubectlOptions has no json tags, and staticcheck SA1026 because it sees the
26-
// func inside RestConfig. RestConfig is nil here, so the only func in play is NodePublicIPLookup, which is
27-
// exactly what this test is pinning. Note that marshalling options with RestConfig set fails independently of
28-
// this field; that is pre-existing behaviour on main and out of scope for this change.
29-
raw, err := json.Marshal(options) //nolint:musttag,staticcheck // see comment above
27+
raw, err := json.Marshal(options) //nolint:musttag // KubectlOptions does not have json tags
3028
require.NoError(t, err, "options carrying a lookup func must still marshal")
3129
assert.NotContains(t, string(raw), "NodePublicIPLookup", "the func field must be omitted from JSON")
3230

@@ -37,3 +35,35 @@ func TestKubectlOptionsMarshalsWithLookupSet(t *testing.T) {
3735
assert.Equal(t, "default", round.Namespace)
3836
assert.Nil(t, round.NodePublicIPLookup, "func field is intentionally not persisted")
3937
}
38+
39+
// TestKubectlOptionsMarshalsWithRestConfigSet covers the other unserializable field. rest.Config holds func-typed
40+
// fields, and encoding/json rejects a func field whether or not it is set, so before the json:"-" tag any marshal
41+
// of options built by NewKubectlOptionsWithRestConfig failed with "unsupported type: transport.WrapperFunc".
42+
func TestKubectlOptionsMarshalsWithRestConfigSet(t *testing.T) {
43+
t.Parallel()
44+
45+
testCases := []struct {
46+
name string
47+
config *rest.Config
48+
}{
49+
{"plain config", &rest.Config{Host: "https://example.com"}},
50+
{"config with WrapTransport set", &rest.Config{
51+
Host: "https://example.com",
52+
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
53+
return rt
54+
},
55+
}},
56+
}
57+
58+
for _, testCase := range testCases {
59+
t.Run(testCase.name, func(t *testing.T) {
60+
t.Parallel()
61+
62+
options := k8s.NewKubectlOptionsWithRestConfig(testCase.config, "default")
63+
64+
raw, err := json.Marshal(options) //nolint:musttag // KubectlOptions does not have json tags
65+
require.NoError(t, err, "options carrying a RestConfig must still marshal")
66+
assert.NotContains(t, string(raw), "RestConfig", "the config must be omitted from JSON")
67+
})
68+
}
69+
}

modules/k8s/save_test_data.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,24 @@ const kubectlOptionsFilename = "KubectlOptions.json"
1111

1212
// SaveKubectlOptions serializes and saves KubectlOptions into the given folder. This allows you to create a
1313
// KubectlOptions during setup and reuse that KubectlOptions later during validation and teardown.
14+
//
15+
// Options carrying a RestConfig cannot be saved and will fail the test. RestConfig is not serializable: beyond its
16+
// func-typed fields it holds interfaces and exec credential plugin wiring that cannot be rebuilt from JSON, and the
17+
// fields that would survive are the credentials themselves, which have no business being written to .test-data.
18+
// Failing here is deliberate. Dropping the config silently would let LoadKubectlOptions return options that fall
19+
// back to the ambient kubeconfig and authenticate against a different cluster than the one under test.
1420
func SaveKubectlOptions(t testing.TestingT, testFolder string, kubectlOptions *KubectlOptions) {
21+
if kubectlOptions != nil && kubectlOptions.RestConfig != nil {
22+
t.Fatalf(
23+
"SaveKubectlOptions cannot save options built with a RestConfig, because a rest.Config cannot be "+
24+
"serialized. Save the values needed to rebuild it instead, or use options built from a kubeconfig "+
25+
"path and context name. Path that would have been written: %s",
26+
formatKubectlOptionsPath(testFolder),
27+
)
28+
29+
return
30+
}
31+
1532
teststate.Save(t, formatKubectlOptionsPath(testFolder), true, kubectlOptions)
1633
}
1734

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package k8s_test
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"runtime"
7+
"strings"
8+
"testing"
9+
10+
"github.com/gruntwork-io/terratest/modules/k8s/v2"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"k8s.io/client-go/rest"
14+
)
15+
16+
// fatalRecorder captures a Fatalf instead of failing the enclosing test. FailNow semantics require that the call
17+
// does not return, so Fatalf ends the goroutine the way testing.T would.
18+
type fatalRecorder struct {
19+
failed bool
20+
msg string
21+
}
22+
23+
func (r *fatalRecorder) Fail() { r.failed = true }
24+
func (r *fatalRecorder) FailNow() { r.failed = true; runtime.Goexit() }
25+
func (r *fatalRecorder) Error(args ...any) { r.failed = true }
26+
func (r *fatalRecorder) Errorf(string, ...any) { r.failed = true }
27+
func (r *fatalRecorder) Fatal(args ...any) { r.msg = fmt.Sprint(args...); r.FailNow() }
28+
func (r *fatalRecorder) Fatalf(format string, args ...any) {
29+
r.msg = fmt.Sprintf(format, args...)
30+
r.FailNow()
31+
}
32+
func (r *fatalRecorder) Name() string { return "fatalRecorder" }
33+
func (r *fatalRecorder) Helper() {}
34+
35+
// runAndRecover runs fn on its own goroutine so a FailNow inside it does not end the calling test.
36+
func runAndRecover(fn func()) {
37+
done := make(chan struct{})
38+
39+
go func() {
40+
defer close(done)
41+
fn()
42+
}()
43+
44+
<-done
45+
}
46+
47+
// TestSaveKubectlOptionsRejectsRestConfig pins that saving options built with a RestConfig fails loudly rather
48+
// than writing a file whose reload would silently target the ambient kubeconfig cluster.
49+
func TestSaveKubectlOptionsRejectsRestConfig(t *testing.T) {
50+
t.Parallel()
51+
52+
folder := t.TempDir()
53+
options := k8s.NewKubectlOptionsWithRestConfig(&rest.Config{Host: "https://example.com"}, "default")
54+
55+
recorder := &fatalRecorder{}
56+
runAndRecover(func() { k8s.SaveKubectlOptions(recorder, folder, options) })
57+
58+
assert.True(t, recorder.failed, "saving options with a RestConfig must fail the test")
59+
assert.Contains(t, recorder.msg, "RestConfig", "the message must name the offending field")
60+
assert.NoFileExists(t, filepath.Join(folder, ".test-data", "KubectlOptions.json"),
61+
"no file may be written when the save is rejected")
62+
}
63+
64+
// TestSaveKubectlOptionsAcceptsKubeconfigOptions is the companion: the ordinary path is untouched.
65+
func TestSaveKubectlOptionsAcceptsKubeconfigOptions(t *testing.T) {
66+
t.Parallel()
67+
68+
folder := t.TempDir()
69+
options := k8s.NewKubectlOptions("terratest-context", "~/.kube/config", "default")
70+
71+
k8s.SaveKubectlOptions(t, folder, options)
72+
require.FileExists(t, filepath.Join(folder, ".test-data", "KubectlOptions.json"))
73+
74+
loaded := k8s.LoadKubectlOptions(t, folder)
75+
assert.Equal(t, "terratest-context", loaded.ContextName)
76+
assert.Equal(t, "default", loaded.Namespace)
77+
assert.Nil(t, loaded.RestConfig)
78+
}
79+
80+
// TestSaveKubectlOptionsMessageIsActionable keeps the failure message useful rather than just a marshal error.
81+
func TestSaveKubectlOptionsMessageIsActionable(t *testing.T) {
82+
t.Parallel()
83+
84+
recorder := &fatalRecorder{}
85+
options := k8s.NewKubectlOptionsWithRestConfig(&rest.Config{Host: "https://example.com"}, "default")
86+
runAndRecover(func() { k8s.SaveKubectlOptions(recorder, t.TempDir(), options) })
87+
88+
assert.NotContains(t, strings.ToLower(recorder.msg), "unsupported type",
89+
"the caller should not be shown a raw encoding/json error")
90+
assert.Contains(t, recorder.msg, "kubeconfig", "the message should point at the supported alternative")
91+
}

0 commit comments

Comments
 (0)