Skip to content

Commit 36ba1c2

Browse files
authored
feat: add a default client with remote client configuration options (#1267)
## What Changed? Why? Adding a config setup where a user can supply a list of configurations for various remote k8s clients. This is going to be used to setup remote clients for app manifest api server, see: grafana/grafana-enterprise#11047 ### How was it tested? Added unit test and it is working in grafana/grafana-enterprise#11047
1 parent 7c68736 commit 36ba1c2

2 files changed

Lines changed: 198 additions & 10 deletions

File tree

k8s/client.go

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"fmt"
8+
"net/http"
89

910
"github.com/prometheus/client_golang/prometheus"
1011
"k8s.io/apimachinery/pkg/runtime"
@@ -14,9 +15,7 @@ import (
1415
"github.com/grafana/grafana-app-sdk/resource"
1516
)
1617

17-
var (
18-
_ resource.Client = &Client{}
19-
)
18+
var _ resource.Client = &Client{}
2019

2120
// Client is a kubernetes-specific implementation of resource.Client, using custom resource definitions.
2221
// A Client is specific to the Schema it was created with.
@@ -116,10 +115,67 @@ func NewClientWithRESTInterface(
116115
}, nil
117116
}
118117

118+
type RemoteRestConfig struct {
119+
Host string
120+
TLSClientConfig rest.TLSClientConfig
121+
WrapTransport func(rt http.RoundTripper) http.RoundTripper
122+
// OverrideAuth, when set to true, will cause the kubeConfig fields related to authentication (BearerToken, BearerTokenFile, CertData, CertFile, KeyData, KeyFile) to be cleared when overlaying the RemoteRestConfig onto the base kubeConfig. This is useful in cases where the remote API server uses a different authentication mechanism than the local kubeConfig.
123+
OverrideAuth bool
124+
}
125+
126+
// NewClientConfigWithExternalClients creates a ClientConfig that will use the RemoteRestConfig map to route
127+
// requests to different API servers based on the group of the resource.Kind being requested.
128+
func NewClientConfigWithExternalClients(remoteRestConfigsByGroup map[string]*RemoteRestConfig) ClientConfig {
129+
config := DefaultClientConfig()
130+
config.KubeConfigProvider = func(kind resource.Kind, kubeConfig rest.Config) rest.Config {
131+
if kubeConfig.APIPath != "" {
132+
return kubeConfig // Don't modify the kubeConfig if the APIPath is already configured
133+
}
134+
// If it isn't configured, set the APIPath based on the kind's group
135+
if kind.Group() == "" {
136+
kubeConfig.APIPath = "/api"
137+
} else {
138+
kubeConfig.APIPath = "/apis"
139+
}
140+
141+
if remoteCfg, ok := remoteRestConfigsByGroup[kind.Group()]; ok && remoteCfg != nil {
142+
kubeConfig = overlayRemoteRestConfig(kubeConfig, remoteCfg)
143+
}
144+
145+
return kubeConfig
146+
}
147+
148+
return config
149+
}
150+
151+
// overlayRemoteRestConfig takes the provided kubeConfig and overlays the Host, TLSClientConfig, and WrapTransport
152+
// In standalone apiserver mode, the default KubeConfig points to the
153+
// local loopback which doesn't serve remote resources.
154+
// We overlay the remote connection details (host, TLS, auth) onto the
155+
// base kubeConfig to preserve SDK-set fields like ContentConfig.GroupVersion.
156+
func overlayRemoteRestConfig(kubeConfig rest.Config, remoteCfg *RemoteRestConfig) rest.Config {
157+
kubeConfig.Host = remoteCfg.Host
158+
kubeConfig.TLSClientConfig = remoteCfg.TLSClientConfig
159+
kubeConfig.WrapTransport = remoteCfg.WrapTransport
160+
161+
// Clear inherited auth that doesn't apply to the remote target.
162+
if remoteCfg.OverrideAuth {
163+
kubeConfig.BearerToken = ""
164+
kubeConfig.BearerTokenFile = ""
165+
kubeConfig.CertData = nil
166+
kubeConfig.CertFile = ""
167+
kubeConfig.KeyData = nil
168+
kubeConfig.KeyFile = ""
169+
}
170+
171+
return kubeConfig
172+
}
173+
119174
// List lists resources in the provided namespace.
120175
// For resources with a schema.Scope() of ClusterScope, `namespace` must be resource.NamespaceAll
121176
func (c *Client) List(ctx context.Context, namespace string, options resource.ListOptions) (
122-
resource.ListObject, error) {
177+
resource.ListObject, error,
178+
) {
123179
into := c.schema.ZeroListValue()
124180
err := c.client.list(ctx, namespace, c.schema.Plural(), into, options, func(raw []byte) (resource.Object, error) {
125181
into := c.schema.ZeroValue()
@@ -134,7 +190,8 @@ func (c *Client) List(ctx context.Context, namespace string, options resource.Li
134190

135191
// ListInto lists resources in the provided namespace, and unmarshals the response into the provided resource.ListObject
136192
func (c *Client) ListInto(ctx context.Context, namespace string, options resource.ListOptions,
137-
into resource.ListObject) error {
193+
into resource.ListObject,
194+
) error {
138195
if c.schema.Scope() == resource.ClusterScope && namespace != resource.NamespaceAll {
139196
return fmt.Errorf("cannot list resources with schema scope \"%s\" in namespace \"%s\", must be NamespaceAll (\"%s\")",
140197
resource.ClusterScope, namespace, resource.NamespaceAll)
@@ -212,7 +269,8 @@ func (c *Client) CreateInto(
212269

213270
// Update updates the provided resource, and returns the updated resource from kubernetes
214271
func (c *Client) Update(ctx context.Context, identifier resource.Identifier, obj resource.Object,
215-
options resource.UpdateOptions) (resource.Object, error) {
272+
options resource.UpdateOptions,
273+
) (resource.Object, error) {
216274
if obj == nil {
217275
return nil, errors.New("obj cannot be nil")
218276
}
@@ -226,7 +284,8 @@ func (c *Client) Update(ctx context.Context, identifier resource.Identifier, obj
226284

227285
// UpdateInto updates the provided resource, and marshals the updated resource from kubernetes into `into`
228286
func (c *Client) UpdateInto(ctx context.Context, identifier resource.Identifier, obj resource.Object,
229-
options resource.UpdateOptions, into resource.Object) error {
287+
options resource.UpdateOptions, into resource.Object,
288+
) error {
230289
if obj == nil {
231290
return errors.New("obj cannot be nil")
232291
}
@@ -260,7 +319,8 @@ func (c *Client) UpdateInto(ctx context.Context, identifier resource.Identifier,
260319

261320
// Patch performs a JSON Patch on the provided resource, and returns the updated object
262321
func (c *Client) Patch(ctx context.Context, identifier resource.Identifier, patch resource.PatchRequest,
263-
options resource.PatchOptions) (resource.Object, error) {
322+
options resource.PatchOptions,
323+
) (resource.Object, error) {
264324
into := c.schema.ZeroValue()
265325
err := c.PatchInto(ctx, identifier, patch, options, into)
266326
if err != nil {
@@ -271,7 +331,8 @@ func (c *Client) Patch(ctx context.Context, identifier resource.Identifier, patc
271331

272332
// PatchInto performs a JSON Patch on the provided resource, and marshals the updated version into the `into` field
273333
func (c *Client) PatchInto(ctx context.Context, identifier resource.Identifier, patch resource.PatchRequest,
274-
options resource.PatchOptions, into resource.Object) error {
334+
options resource.PatchOptions, into resource.Object,
335+
) error {
275336
return c.client.patch(ctx, identifier, c.schema.Plural(), patch, into, options, c.codec)
276337
}
277338

@@ -283,7 +344,8 @@ func (c *Client) Delete(ctx context.Context, identifier resource.Identifier, opt
283344
// Watch makes a watch request for the namespace, and returns a WatchResponse which wraps a kubernetes
284345
// watch.Interface. The underlying watch.Interface can be accessed using KubernetesWatch()
285346
func (c *Client) Watch(ctx context.Context, namespace string, options resource.WatchOptions) (
286-
resource.WatchResponse, error) {
347+
resource.WatchResponse, error,
348+
) {
287349
if c.schema.Scope() == resource.ClusterScope && namespace != resource.NamespaceAll {
288350
return nil, fmt.Errorf("cannot watch resources with schema scope \"%s\" in namespace \"%s\", must be NamespaceAll (\"%s\")",
289351
resource.ClusterScope, namespace, resource.NamespaceAll)

k8s/client_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,132 @@ func TestDefaultClientConfig(t *testing.T) {
8585
})
8686
}
8787

88+
func TestNewClientConfigWithExternalClients(t *testing.T) {
89+
remoteWrapTransportInvoked := false
90+
remoteWrapTransport := func(rt http.RoundTripper) http.RoundTripper {
91+
remoteWrapTransportInvoked = true
92+
return rt
93+
}
94+
remoteByGroup := map[string]*RemoteRestConfig{
95+
testKind.Group(): {
96+
Host: "https://remote.example.com",
97+
TLSClientConfig: rest.TLSClientConfig{
98+
Insecure: true,
99+
},
100+
WrapTransport: remoteWrapTransport,
101+
OverrideAuth: true,
102+
},
103+
}
104+
config := NewClientConfigWithExternalClients(remoteByGroup)
105+
106+
t.Run("sets APIPath to /apis for custom kinds without remote entry", func(t *testing.T) {
107+
otherKind := resource.Kind{
108+
Schema: resource.NewSimpleSchema(
109+
"other.group",
110+
"v1",
111+
&resource.UntypedObject{},
112+
&resource.UntypedList{},
113+
resource.WithKind("Other"),
114+
),
115+
}
116+
117+
provided := config.KubeConfigProvider(otherKind, rest.Config{})
118+
assert.Equal(t, "/apis", provided.APIPath)
119+
assert.Empty(t, provided.Host)
120+
})
121+
122+
t.Run("sets APIPath to /api for empty-group kinds", func(t *testing.T) {
123+
legacyKind := resource.Kind{
124+
Schema: resource.NewSimpleSchema("", "v1", &resource.UntypedObject{}, &resource.UntypedList{}),
125+
}
126+
127+
provided := config.KubeConfigProvider(legacyKind, rest.Config{})
128+
assert.Equal(t, "/api", provided.APIPath)
129+
assert.Empty(t, provided.Host)
130+
})
131+
132+
t.Run("overlays remote config for mapped group and clears inherited auth", func(t *testing.T) {
133+
base := rest.Config{
134+
Host: "https://local.example.com",
135+
BearerToken: "token",
136+
BearerTokenFile: "/tmp/token",
137+
TLSClientConfig: rest.TLSClientConfig{
138+
CertData: []byte("cert"),
139+
CertFile: "/tmp/cert",
140+
KeyData: []byte("key"),
141+
KeyFile: "/tmp/key",
142+
},
143+
}
144+
145+
provided := config.KubeConfigProvider(testKind, base)
146+
assert.Equal(t, "/apis", provided.APIPath)
147+
assert.Equal(t, "https://remote.example.com", provided.Host)
148+
assert.True(t, provided.TLSClientConfig.Insecure)
149+
require.NotNil(t, provided.WrapTransport)
150+
assert.Empty(t, provided.BearerToken)
151+
assert.Empty(t, provided.BearerTokenFile)
152+
assert.Nil(t, provided.CertData)
153+
assert.Empty(t, provided.CertFile)
154+
assert.Nil(t, provided.KeyData)
155+
assert.Empty(t, provided.KeyFile)
156+
157+
_ = provided.WrapTransport(http.DefaultTransport)
158+
assert.True(t, remoteWrapTransportInvoked)
159+
})
160+
161+
t.Run("does not modify config when APIPath already set", func(t *testing.T) {
162+
existing := rest.Config{
163+
APIPath: "/already-configured",
164+
Host: "https://local.example.com",
165+
BearerToken: "keep-me",
166+
BearerTokenFile: "/tmp/token",
167+
TLSClientConfig: rest.TLSClientConfig{
168+
CertData: []byte("cert"),
169+
CertFile: "/tmp/cert",
170+
KeyData: []byte("key"),
171+
KeyFile: "/tmp/key",
172+
},
173+
}
174+
175+
provided := config.KubeConfigProvider(testKind, existing)
176+
assert.Equal(t, existing, provided)
177+
})
178+
179+
t.Run("keeps inherited auth when OverrideAuth is false", func(t *testing.T) {
180+
configNoAuthOverride := NewClientConfigWithExternalClients(map[string]*RemoteRestConfig{
181+
testKind.Group(): {
182+
Host: "https://remote.example.com",
183+
TLSClientConfig: rest.TLSClientConfig{
184+
Insecure: true,
185+
},
186+
},
187+
})
188+
189+
base := rest.Config{
190+
Host: "https://local.example.com",
191+
BearerToken: "token",
192+
BearerTokenFile: "/tmp/token",
193+
TLSClientConfig: rest.TLSClientConfig{
194+
CertData: []byte("cert"),
195+
CertFile: "/tmp/cert",
196+
KeyData: []byte("key"),
197+
KeyFile: "/tmp/key",
198+
},
199+
}
200+
201+
provided := configNoAuthOverride.KubeConfigProvider(testKind, base)
202+
assert.Equal(t, "/apis", provided.APIPath)
203+
assert.Equal(t, "https://remote.example.com", provided.Host)
204+
assert.Equal(t, "token", provided.BearerToken)
205+
assert.Equal(t, "/tmp/token", provided.BearerTokenFile)
206+
assert.True(t, provided.TLSClientConfig.Insecure)
207+
assert.Nil(t, provided.CertData)
208+
assert.Empty(t, provided.CertFile)
209+
assert.Nil(t, provided.KeyData)
210+
assert.Empty(t, provided.KeyFile)
211+
})
212+
}
213+
88214
func TestClient_Get(t *testing.T) {
89215
client, server := getClientTestSetup(testKind)
90216
defer server.Close()

0 commit comments

Comments
 (0)