Extended kubeconfig interface

Added kubeconfig extraction from secret stored in kubernetes cluster

Change-Id: I4399d718857614c6d6dfec2174092cd84c4ee22f
This commit is contained in:
Stanislav Egorov 2020-09-01 11:32:46 -07:00
parent eaa66fded7
commit f0df007945
4 changed files with 279 additions and 0 deletions

View File

@ -14,6 +14,10 @@
package kubeconfig
import (
"fmt"
)
// ErrKubeConfigPathEmpty returned when kubeconfig path is not specified
type ErrKubeConfigPathEmpty struct {
}
@ -21,3 +25,27 @@ type ErrKubeConfigPathEmpty struct {
func (e *ErrKubeConfigPathEmpty) Error() string {
return "kubeconfig path is not defined"
}
// ErrClusterNameEmpty returned when cluster name is not provided
type ErrClusterNameEmpty struct {
}
func (e ErrClusterNameEmpty) Error() string {
return "cluster name is not defined"
}
// ErrMalformedSecret error returned if secret data value is lost or empty
type ErrMalformedSecret struct {
ClusterName string
Namespace string
SecretName string
}
func (e ErrMalformedSecret) Error() string {
return fmt.Sprintf(
"can't retrieve data from secret %s in cluster %s(namespace: %s)",
e.SecretName,
e.ClusterName,
e.Namespace,
)
}

View File

@ -88,6 +88,13 @@ func FromAPIalphaV1(apiObj *v1alpha1.KubeConfig) KubeSourceFunc {
}
}
// FromSecret returns KubeSource type, uses client interface to kubernetes cluster
func FromSecret(kubeOpts *FromClusterOptions) KubeSourceFunc {
return func() ([]byte, error) {
return GetKubeconfigFromSecret(kubeOpts)
}
}
// FromFile returns KubeSource type, uses path to kubeconfig on FS as source to construct kubeconfig object
func FromFile(path string, fs document.FileSystem) KubeSourceFunc {
return func() ([]byte, error) {

View File

@ -23,16 +23,22 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
coreV1 "k8s.io/api/core/v1"
metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "k8s.io/client-go/tools/clientcmd/api/v1"
kustfs "sigs.k8s.io/kustomize/api/filesys"
"opendev.org/airship/airshipctl/pkg/api/v1alpha1"
"opendev.org/airship/airshipctl/pkg/document"
"opendev.org/airship/airshipctl/pkg/k8s/client/fake"
"opendev.org/airship/airshipctl/pkg/k8s/kubeconfig"
"opendev.org/airship/airshipctl/testutil/fs"
)
const (
testClusterName = "dummy_target_cluster"
testSecretName = testClusterName + "-kubeconfig"
testNamespace = "default"
testValidKubeconfig = `apiVersion: v1
clusters:
- cluster:
@ -130,6 +136,171 @@ func TestKubeconfigContent(t *testing.T) {
assert.Equal(t, expectedData, actualData)
}
func TestFromSecret(t *testing.T) {
tests := []struct {
name string
opts *kubeconfig.FromClusterOptions
acc fake.ResourceAccumulator
expectedData []byte
err error
}{
{
name: "valid kubeconfig",
opts: &kubeconfig.FromClusterOptions{
ClusterName: testClusterName,
Namespace: testNamespace,
},
acc: fake.WithTypedObjects(&coreV1.Secret{
TypeMeta: metaV1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metaV1.ObjectMeta{
Name: testSecretName,
Namespace: testNamespace,
},
Data: map[string][]byte{
"value": []byte(testValidKubeconfig),
},
}),
expectedData: []byte(testValidKubeconfig),
err: nil,
},
{
name: "no cluster name",
opts: &kubeconfig.FromClusterOptions{
ClusterName: "",
Namespace: testNamespace,
},
acc: fake.WithTypedObjects(&coreV1.Secret{
TypeMeta: metaV1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metaV1.ObjectMeta{
Name: testSecretName,
Namespace: testNamespace,
},
Data: map[string][]byte{
"value": []byte(testValidKubeconfig),
},
}),
expectedData: nil,
err: kubeconfig.ErrClusterNameEmpty{},
},
{
name: "default namespace",
opts: &kubeconfig.FromClusterOptions{
ClusterName: testClusterName,
Namespace: "",
},
acc: fake.WithTypedObjects(&coreV1.Secret{
TypeMeta: metaV1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metaV1.ObjectMeta{
Name: testSecretName,
Namespace: testNamespace,
},
Data: map[string][]byte{
"value": []byte(testValidKubeconfig),
},
}),
expectedData: []byte(testValidKubeconfig),
err: nil,
},
{
name: "no data in secret",
opts: &kubeconfig.FromClusterOptions{
ClusterName: testClusterName,
Namespace: testNamespace,
},
acc: fake.WithTypedObjects(&coreV1.Secret{
TypeMeta: metaV1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metaV1.ObjectMeta{
Name: testSecretName,
Namespace: testNamespace,
},
}),
expectedData: nil,
err: kubeconfig.ErrMalformedSecret{
ClusterName: testClusterName,
Namespace: testNamespace,
SecretName: testSecretName,
},
},
{
name: "empty data in secret",
opts: &kubeconfig.FromClusterOptions{
ClusterName: testClusterName,
Namespace: testNamespace,
},
acc: fake.WithTypedObjects(&coreV1.Secret{
TypeMeta: metaV1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metaV1.ObjectMeta{
Name: testSecretName,
Namespace: testNamespace,
},
Data: map[string][]byte{},
}),
expectedData: nil,
err: kubeconfig.ErrMalformedSecret{
ClusterName: testClusterName,
Namespace: testNamespace,
SecretName: testSecretName,
},
},
{
name: "empty value in data in secret",
opts: &kubeconfig.FromClusterOptions{
ClusterName: testClusterName,
Namespace: testNamespace,
},
acc: fake.WithTypedObjects(&coreV1.Secret{
TypeMeta: metaV1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metaV1.ObjectMeta{
Name: testSecretName,
Namespace: testNamespace,
},
Data: map[string][]byte{
"value": []byte(""),
},
}),
expectedData: nil,
err: kubeconfig.ErrMalformedSecret{
ClusterName: testClusterName,
Namespace: testNamespace,
SecretName: testSecretName,
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
tt.opts.Client = fake.NewClient(tt.acc)
kubeconf, err := kubeconfig.FromSecret(tt.opts)()
if tt.err != nil {
assert.Equal(t, tt.err, err)
assert.Nil(t, kubeconf)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedData, kubeconf)
}
})
}
}
func TestFromBundle(t *testing.T) {
tests := []struct {
name string
@ -171,6 +342,7 @@ func TestFromBundle(t *testing.T) {
})
}
}
func TestNewKubeConfig(t *testing.T) {
tests := []struct {
shouldPanic bool

View File

@ -0,0 +1,72 @@
/*
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
https://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 kubeconfig
import (
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"opendev.org/airship/airshipctl/pkg/k8s/client"
"opendev.org/airship/airshipctl/pkg/log"
)
// FromClusterOptions holds all configurable options for kubeconfig extraction
type FromClusterOptions struct {
ClusterName string
Namespace string
Client client.Interface
}
// GetKubeconfigFromSecret extracts kubeconfig from secret data structure
func GetKubeconfigFromSecret(o *FromClusterOptions) ([]byte, error) {
const defaultNamespace = "default"
if o.ClusterName == "" {
return nil, ErrClusterNameEmpty{}
}
if o.Namespace == "" {
log.Printf("Namespace is not provided, using default one")
o.Namespace = defaultNamespace
}
log.Debugf("Extracting kubeconfig from secret in cluster %s(namespace: %s)", o.ClusterName, o.Namespace)
secretName := fmt.Sprintf("%s-kubeconfig", o.ClusterName)
kubeCore := o.Client.ClientSet().CoreV1()
secret, err := kubeCore.Secrets(o.Namespace).Get(secretName, metav1.GetOptions{})
if err != nil {
return nil, err
}
if secret.Data == nil {
return nil, ErrMalformedSecret{
ClusterName: o.ClusterName,
Namespace: o.Namespace,
SecretName: secretName,
}
}
val, exist := secret.Data["value"]
if !exist || len(val) == 0 {
return nil, ErrMalformedSecret{
ClusterName: o.ClusterName,
Namespace: o.Namespace,
SecretName: secretName,
}
}
return val, nil
}