Merge branch 'release/2.9'

This commit is contained in:
yi-portainer
2021-09-23 10:54:30 +12:00
836 changed files with 27507 additions and 9737 deletions
+33 -53
View File
@@ -9,22 +9,43 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type (
namespaceAccessPolicies map[string]portainer.K8sNamespaceAccessPolicy
)
// NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace
func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error {
kcl.lock.Lock()
defer kcl.lock.Unlock()
func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string) error {
policies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return errors.WithMessage(err, "failed to fetch access policies")
}
delete(policies, ns)
return kcl.UpdateNamespaceAccessPolicies(policies)
}
// GetNamespaceAccessPolicies gets the namespace access policies
// from config maps in the portainer namespace
func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) {
configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{})
if k8serrors.IsNotFound(err) {
return nil
return nil, nil
} else if err != nil {
return err
return nil, err
}
accessData := configMap.Data[portainerConfigMapAccessPoliciesKey]
var accessPolicies namespaceAccessPolicies
err = json.Unmarshal([]byte(accessData), &accessPolicies)
var policies map[string]portainer.K8sNamespaceAccessPolicy
err = json.Unmarshal([]byte(accessData), &policies)
if err != nil {
return nil, err
}
return policies, nil
}
func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string, restrictDefaultNamespace bool) error {
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return err
}
@@ -35,20 +56,16 @@ func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, service
}
for _, namespace := range namespaces.Items {
if namespace.Name == defaultNamespace {
continue
}
policies, ok := accessPolicies[namespace.Name]
if !ok {
err = kcl.removeNamespaceAccessForServiceAccount(serviceAccountName, namespace.Name)
if namespace.Name == defaultNamespace && !restrictDefaultNamespace {
err = kcl.ensureNamespaceAccessForServiceAccount(serviceAccountName, defaultNamespace)
if err != nil {
return err
}
continue
}
if !hasUserAccessToNamespace(userID, teamIDs, policies) {
policies, ok := accessPolicies[namespace.Name]
if !ok || !hasUserAccessToNamespace(userID, teamIDs, policies) {
err = kcl.removeNamespaceAccessForServiceAccount(serviceAccountName, namespace.Name)
if err != nil {
return err
@@ -81,43 +98,6 @@ func hasUserAccessToNamespace(userID int, teamIDs []int, policies portainer.K8sN
return false
}
// NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace
func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error {
kcl.lock.Lock()
defer kcl.lock.Unlock()
policies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return errors.WithMessage(err, "failed to fetch access policies")
}
delete(policies, ns)
return kcl.UpdateNamespaceAccessPolicies(policies)
}
// GetNamespaceAccessPolicies gets the namespace access policies
// from config maps in the portainer namespace
func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) {
configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{})
if k8serrors.IsNotFound(err) {
return nil, nil
}
if err != nil {
return nil, err
}
accessData := configMap.Data[portainerConfigMapAccessPoliciesKey]
var policies map[string]portainer.K8sNamespaceAccessPolicy
err = json.Unmarshal([]byte(accessData), &policies)
if err != nil {
return nil, err
}
return policies, nil
}
// UpdateNamespaceAccessPolicies updates the namespace access policies
func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]portainer.K8sNamespaceAccessPolicy) error {
data, err := json.Marshal(accessPolicies)
+40 -16
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"sync"
"time"
cmap "github.com/orcaman/concurrent-map"
@@ -18,6 +19,7 @@ import (
type (
// ClientFactory is used to create Kubernetes clients
ClientFactory struct {
dataStore portainer.DataStore
reverseTunnelService portainer.ReverseTunnelService
signatureService portainer.DigitalSignatureService
instanceID string
@@ -33,8 +35,9 @@ type (
)
// NewClientFactory returns a new instance of a ClientFactory
func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *ClientFactory {
func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore portainer.DataStore) *ClientFactory {
return &ClientFactory{
dataStore: dataStore,
signatureService: signatureService,
reverseTunnelService: reverseTunnelService,
instanceID: instanceID,
@@ -47,7 +50,7 @@ func (factory *ClientFactory) RemoveKubeClient(endpoint *portainer.Endpoint) {
factory.endpointClients.Remove(strconv.Itoa(int(endpoint.ID)))
}
// GetKubeClient checks if an existing client is already registered for the endpoint and returns it if one is found.
// GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found.
// If no client is registered, it will create a new client, register it, and returns it.
func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) {
key := strconv.Itoa(int(endpoint.ID))
@@ -91,7 +94,7 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kuber
return factory.buildEdgeClient(endpoint)
}
return nil, errors.New("unsupported endpoint type")
return nil, errors.New("unsupported environment type")
}
type agentHeaderRoundTripper struct {
@@ -112,6 +115,40 @@ func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response,
func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
return factory.createRemoteClient(endpointURL);
}
func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
if tunnel.Status == portainer.EdgeAgentIdle {
err := factory.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
if err != nil {
return nil, fmt.Errorf("failed opening tunnel to environment: %w", err)
}
if endpoint.EdgeCheckinInterval == 0 {
settings, err := factory.dataStore.Settings().Settings()
if err != nil {
return nil, fmt.Errorf("failed fetching settings from db: %w", err)
}
endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
waitForAgentToConnect := time.Duration(endpoint.EdgeCheckinInterval) * time.Second
time.Sleep(waitForAgentToConnect * 2)
tunnel = factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
return factory.createRemoteClient(endpointURL);
}
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
@@ -134,19 +171,6 @@ func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*k
return kubernetes.NewForConfig(config)
}
func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
endpointURL := fmt.Sprintf("http://localhost:%d/kubernetes", tunnel.Port)
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
return nil, err
}
config.Insecure = true
return kubernetes.NewForConfig(config)
}
func buildLocalClient() (*kubernetes.Clientset, error) {
config, err := rest.InClusterConfig()
if err != nil {
+14 -8
View File
@@ -4,7 +4,7 @@ import (
"errors"
"io"
"k8s.io/api/core/v1"
v1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
@@ -14,11 +14,18 @@ import (
// StartExecProcess will start an exec process inside a container located inside a pod inside a specific namespace
// using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write
// to the stdout parameter.
// This function only works against a local endpoint using an in-cluster config with the user's SA token.
func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error {
// This function only works against a local environment(endpoint) using an in-cluster config with the user's SA token.
// This is a blocking operation.
func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) {
config, err := rest.InClusterConfig()
if err != nil {
return err
errChan <- err
return
}
if !useAdminToken {
config.BearerToken = token
config.BearerTokenFile = ""
}
if !useAdminToken {
@@ -44,7 +51,8 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
return err
errChan <- err
return
}
err = exec.Stream(remotecommand.StreamOptions{
@@ -54,9 +62,7 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
})
if err != nil {
if _, ok := err.(utilexec.ExitError); !ok {
return errors.New("unable to start exec process")
errChan <- errors.New("unable to start exec process")
}
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
package cli
import (
"context"
"fmt"
portainer "github.com/portainer/portainer/api"
clientV1 "k8s.io/client-go/tools/clientcmd/api/v1"
)
// GetKubeConfig returns kubeconfig for the current user based on:
// - portainer server url
// - portainer user bearer token
// - portainer token data - which maps to k8s service account
func (kcl *KubeClient) GetKubeConfig(ctx context.Context, apiServerURL string, bearerToken string, tokenData *portainer.TokenData) (*clientV1.Config, error) {
serviceAccount, err := kcl.GetServiceAccount(tokenData)
if err != nil {
errText := fmt.Sprintf("unable to find serviceaccount associated with user; username=%s", tokenData.Username)
return nil, fmt.Errorf("%s; err=%w", errText, err)
}
kubeconfig := generateKubeconfig(apiServerURL, bearerToken, serviceAccount.Name)
return kubeconfig, nil
}
// generateKubeconfig will generate and return kubeconfig resource - usable by `kubectl` cli
// which will allow the client to connect directly to k8s server environment(endpoint) via portainer (proxy)
func generateKubeconfig(apiServerURL, bearerToken, serviceAccountName string) *clientV1.Config {
const (
KubeConfigPortainerContext = "portainer-ctx"
KubeConfigPortainerCluster = "portainer-cluster"
)
return &clientV1.Config{
APIVersion: "v1",
Kind: "Config",
CurrentContext: KubeConfigPortainerContext,
Contexts: []clientV1.NamedContext{
{
Name: KubeConfigPortainerContext,
Context: clientV1.Context{
AuthInfo: serviceAccountName,
Cluster: KubeConfigPortainerCluster,
},
},
},
Clusters: []clientV1.NamedCluster{
{
Name: KubeConfigPortainerCluster,
Cluster: clientV1.Cluster{
Server: apiServerURL,
InsecureSkipTLSVerify: true,
},
},
},
AuthInfos: []clientV1.NamedAuthInfo{
{
Name: serviceAccountName,
AuthInfo: clientV1.AuthInfo{
Token: bearerToken,
},
},
},
}
}
+150
View File
@@ -0,0 +1,150 @@
package cli
import (
"context"
"errors"
"testing"
portainer "github.com/portainer/portainer/api"
v1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
func Test_GetKubeConfig(t *testing.T) {
t.Run("returns error if SA non-existent", func(t *testing.T) {
k := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
}
tokenData := &portainer.TokenData{
ID: 1,
Role: portainer.AdministratorRole,
Username: portainerClusterAdminServiceAccountName,
}
_, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData)
if err == nil {
t.Error("GetKubeConfig should fail as service account does not exist")
}
if k8sErr := errors.Unwrap(err); !k8serrors.IsNotFound(k8sErr) {
t.Error("GetKubeConfig should fail with service account not found k8s error")
}
})
t.Run("successfully obtains kubeconfig for cluster admin", func(t *testing.T) {
k := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
}
tokenData := &portainer.TokenData{
Role: portainer.AdministratorRole,
Username: portainerClusterAdminServiceAccountName,
}
serviceAccount := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{Name: tokenData.Username},
}
k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount)
defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil)
_, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData)
if err != nil {
t.Errorf("GetKubeConfig should succeed; err=%s", err)
}
})
t.Run("successfully obtains kubeconfig for standard user", func(t *testing.T) {
k := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
}
tokenData := &portainer.TokenData{
ID: 1,
Role: portainer.StandardUserRole,
}
nonAdminUserName := userServiceAccountName(int(tokenData.ID), k.instanceID)
serviceAccount := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{Name: nonAdminUserName},
}
k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount)
defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil)
_, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData)
if err != nil {
t.Errorf("GetKubeConfig should succeed; err=%s", err)
}
})
}
func Test_generateKubeconfig(t *testing.T) {
apiServerURL, bearerToken, serviceAccountName := "localhost", "test-token", "test-user"
t.Run("generates Config resource kind", func(t *testing.T) {
config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
want := "Config"
if config.Kind != want {
t.Errorf("generateKubeconfig resource kind should be %s", want)
}
})
t.Run("generates v1 version", func(t *testing.T) {
config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
want := "v1"
if config.APIVersion != want {
t.Errorf("generateKubeconfig api version should be %s", want)
}
})
t.Run("generates single entry context cluster and authinfo", func(t *testing.T) {
config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
if len(config.Contexts) != 1 {
t.Error("generateKubeconfig should generate single context configuration")
}
if len(config.Clusters) != 1 {
t.Error("generateKubeconfig should generate single cluster configuration")
}
if len(config.AuthInfos) != 1 {
t.Error("generateKubeconfig should generate single user configuration")
}
})
t.Run("sets default context appropriately", func(t *testing.T) {
config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
want := "portainer-ctx"
if config.CurrentContext != want {
t.Errorf("generateKubeconfig set cluster to be %s", want)
}
})
t.Run("generates cluster with InsecureSkipTLSVerify to be set to true", func(t *testing.T) {
config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
if config.Clusters[0].Cluster.InsecureSkipTLSVerify != true {
t.Error("generateKubeconfig default cluster InsecureSkipTLSVerify should be true")
}
})
t.Run("should contain passed in value", func(t *testing.T) {
config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
if config.Clusters[0].Cluster.Server != apiServerURL {
t.Errorf("generateKubeconfig default cluster server url should be %s", apiServerURL)
}
if config.AuthInfos[0].Name != serviceAccountName {
t.Errorf("generateKubeconfig default authinfo name should be %s", serviceAccountName)
}
if config.AuthInfos[0].AuthInfo.Token != bearerToken {
t.Errorf("generateKubeconfig default authinfo user token should be %s", bearerToken)
}
})
}
+73
View File
@@ -0,0 +1,73 @@
package cli
import (
"strconv"
"github.com/pkg/errors"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
systemNamespaceLabel = "io.portainer.kubernetes.namespace.system"
)
func defaultSystemNamespaces() map[string]struct{} {
return map[string]struct{}{
"kube-system": {},
"kube-public": {},
"kube-node-lease": {},
"portainer": {},
}
}
func isSystemNamespace(namespace v1.Namespace) bool {
systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel]
if hasSystemLabel {
return systemLabelValue == "true"
}
systemNamespaces := defaultSystemNamespaces()
_, isSystem := systemNamespaces[namespace.Name]
return isSystem
}
// ToggleSystemState will set a namespace as a system namespace, or remove this state
// if isSystem is true it will set `systemNamespaceLabel` to "true" and false otherwise
// this will skip if namespace is "default" or if the required state is already set
func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) error {
if namespaceName == "default" {
return nil
}
nsService := kcl.cli.CoreV1().Namespaces()
namespace, err := nsService.Get(namespaceName, metav1.GetOptions{})
if err != nil {
return errors.Wrap(err, "failed fetching namespace object")
}
if isSystemNamespace(*namespace) == isSystem {
return nil
}
if namespace.Labels == nil {
namespace.Labels = map[string]string{}
}
namespace.Labels[systemNamespaceLabel] = strconv.FormatBool(isSystem)
_, err = nsService.Update(namespace)
if err != nil {
return errors.Wrap(err, "failed updating namespace object")
}
if isSystem {
return kcl.NamespaceAccessPoliciesDeleteNamespace(namespaceName)
}
return nil
}
+185
View File
@@ -0,0 +1,185 @@
package cli
import (
"strconv"
"sync"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
core "k8s.io/api/core/v1"
ktypes "k8s.io/api/core/v1"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
func Test_ToggleSystemState(t *testing.T) {
t.Run("should skip is default (exit without error)", func(t *testing.T) {
nsName := "default"
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, true)
assert.NoError(t, err)
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
assert.NoError(t, err)
_, exists := ns.Labels[systemNamespaceLabel]
assert.False(t, exists, "system label should not exists")
})
t.Run("should fail if namespace doesn't exist", func(t *testing.T) {
nsName := "not-exist"
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, true)
assert.Error(t, err)
})
t.Run("if called with the same state, should skip (exit without error)", func(t *testing.T) {
nsName := "namespace"
tests := []struct {
isSystem bool
}{
{isSystem: true},
{isSystem: false},
}
for _, test := range tests {
t.Run(strconv.FormatBool(test.isSystem), func(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName, Labels: map[string]string{
systemNamespaceLabel: strconv.FormatBool(test.isSystem),
}}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, test.isSystem)
assert.NoError(t, err)
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
assert.NoError(t, err)
assert.Equal(t, test.isSystem, isSystemNamespace(*ns))
})
}
})
t.Run("for regular namespace if isSystem is true and doesn't have a label, should set the label to true", func(t *testing.T) {
nsName := "namespace"
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, true)
assert.NoError(t, err)
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
assert.NoError(t, err)
labelValue, exists := ns.Labels[systemNamespaceLabel]
assert.True(t, exists, "system label should exists")
assert.Equal(t, "true", labelValue)
})
t.Run("for default system namespace if isSystem is false and doesn't have a label, should set the label to false", func(t *testing.T) {
nsName := "portainer"
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, false)
assert.NoError(t, err)
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
assert.NoError(t, err)
labelValue, exists := ns.Labels[systemNamespaceLabel]
assert.True(t, exists, "system label should exists")
assert.Equal(t, "false", labelValue)
})
t.Run("for system namespace (with label), if called with false, should set the label", func(t *testing.T) {
nsName := "namespace"
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName, Labels: map[string]string{
systemNamespaceLabel: "true",
}}}),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, false)
assert.NoError(t, err)
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
assert.NoError(t, err)
labelValue, exists := ns.Labels[systemNamespaceLabel]
assert.True(t, exists, "system label should exists")
assert.Equal(t, "false", labelValue)
})
t.Run("for non system namespace (with label), if called with true, should set the label, and remove accesses", func(t *testing.T) {
nsName := "ns1"
namespace := &core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName, Labels: map[string]string{
systemNamespaceLabel: "false",
}}}
config := &ktypes.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: portainerConfigMapName,
Namespace: portainerNamespace,
},
Data: map[string]string{
"NamespaceAccessPolicies": `{"ns1":{"UserAccessPolicies":{"2":{"RoleId":0}}}, "ns2":{"UserAccessPolicies":{"2":{"RoleId":0}}}}`,
},
}
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(namespace, config),
instanceID: "instance",
lock: &sync.Mutex{},
}
err := kcl.ToggleSystemState(nsName, true)
assert.NoError(t, err)
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
assert.NoError(t, err)
labelValue, exists := ns.Labels[systemNamespaceLabel]
assert.True(t, exists, "system label should exists")
assert.Equal(t, "true", labelValue)
expectedPolicies := map[string]portainer.K8sNamespaceAccessPolicy{
"ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
}
actualPolicies, err := kcl.GetNamespaceAccessPolicies()
assert.NoError(t, err, "failed to fetch policies")
assert.Equal(t, expectedPolicies, actualPolicies)
})
}
+17 -9
View File
@@ -1,16 +1,20 @@
package cli
import "fmt"
import (
"fmt"
)
const (
defaultNamespace = "default"
portainerNamespace = "portainer"
portainerUserCRName = "portainer-cr-user"
portainerUserCRBName = "portainer-crb-user"
portainerUserServiceAccountPrefix = "portainer-sa-user"
portainerRBPrefix = "portainer-rb"
portainerConfigMapName = "portainer-config"
portainerConfigMapAccessPoliciesKey = "NamespaceAccessPolicies"
defaultNamespace = "default"
portainerNamespace = "portainer"
portainerUserCRName = "portainer-cr-user"
portainerUserCRBName = "portainer-crb-user"
portainerClusterAdminServiceAccountName = "portainer-sa-clusteradmin"
portainerUserServiceAccountPrefix = "portainer-sa-user"
portainerRBPrefix = "portainer-rb"
portainerConfigMapName = "portainer-config"
portainerConfigMapAccessPoliciesKey = "NamespaceAccessPolicies"
portainerShellPodPrefix = "portainer-pod-kubectl-shell"
)
func userServiceAccountName(userID int, instanceID string) string {
@@ -24,3 +28,7 @@ func userServiceAccountTokenSecretName(serviceAccountName string, instanceID str
func namespaceClusterRoleBindingName(namespace string, instanceID string) string {
return fmt.Sprintf("%s-%s-%s", portainerRBPrefix, instanceID, namespace)
}
func userShellPodPrefix(serviceAccountName string) string {
return fmt.Sprintf("%s-%s-", portainerShellPodPrefix, serviceAccountName)
}
+42
View File
@@ -0,0 +1,42 @@
package cli
import (
portainer "github.com/portainer/portainer/api"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetNodesLimits gets the CPU and Memory limits(unused resources) of all nodes in the current k8s environment(endpoint) connection
func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) {
nodesLimits := make(portainer.K8sNodesLimits)
nodes, err := kcl.cli.CoreV1().Nodes().List(metav1.ListOptions{})
if err != nil {
return nil, err
}
pods, err := kcl.cli.CoreV1().Pods("").List(metav1.ListOptions{})
if err != nil {
return nil, err
}
for _, item := range nodes.Items {
cpu := item.Status.Allocatable.Cpu().MilliValue()
memory := item.Status.Allocatable.Memory().Value()
nodesLimits[item.ObjectMeta.Name] = &portainer.K8sNodeLimits{
CPU: cpu,
Memory: memory,
}
}
for _, item := range pods.Items {
if nodeLimits, ok := nodesLimits[item.Spec.NodeName]; ok {
for _, container := range item.Spec.Containers {
nodeLimits.CPU -= container.Resources.Requests.Cpu().MilliValue()
nodeLimits.Memory -= container.Resources.Requests.Memory().Value()
}
}
}
return nodesLimits, nil
}
+137
View File
@@ -0,0 +1,137 @@
package cli
import (
portainer "github.com/portainer/portainer/api"
"k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
kfake "k8s.io/client-go/kubernetes/fake"
"reflect"
"testing"
)
func newNodes() *v1.NodeList {
return &v1.NodeList{
Items: []v1.Node{
{
ObjectMeta: metav1.ObjectMeta{
Name: "test-node-0",
},
Status: v1.NodeStatus{
Allocatable: v1.ResourceList{
v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"),
v1.ResourceName(v1.ResourceMemory): resource.MustParse("4M"),
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "test-node-1",
},
Status: v1.NodeStatus{
Allocatable: v1.ResourceList{
v1.ResourceName(v1.ResourceCPU): resource.MustParse("3"),
v1.ResourceName(v1.ResourceMemory): resource.MustParse("6M"),
},
},
},
},
}
}
func newPods() *v1.PodList {
return &v1.PodList{
Items: []v1.Pod{
{
ObjectMeta: metav1.ObjectMeta{
Name: "test-container-0",
Namespace: "test-namespace-0",
},
Spec: v1.PodSpec{
NodeName: "test-node-0",
Containers: []v1.Container{
{
Name: "test-container-0",
Resources: v1.ResourceRequirements{
Requests: v1.ResourceList{
v1.ResourceName(v1.ResourceCPU): resource.MustParse("1"),
v1.ResourceName(v1.ResourceMemory): resource.MustParse("2M"),
},
},
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "test-container-1",
Namespace: "test-namespace-1",
},
Spec: v1.PodSpec{
NodeName: "test-node-1",
Containers: []v1.Container{
{
Name: "test-container-1",
Resources: v1.ResourceRequirements{
Requests: v1.ResourceList{
v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"),
v1.ResourceName(v1.ResourceMemory): resource.MustParse("3M"),
},
},
},
},
},
},
},
}
}
func TestKubeClient_GetNodesLimits(t *testing.T) {
type fields struct {
cli kubernetes.Interface
}
fieldsInstance := fields{
cli: kfake.NewSimpleClientset(newNodes(), newPods()),
}
tests := []struct {
name string
fields fields
want portainer.K8sNodesLimits
wantErr bool
}{
{
name: "2 nodes 2 pods",
fields: fieldsInstance,
want: portainer.K8sNodesLimits{
"test-node-0": &portainer.K8sNodeLimits{
CPU: 1000,
Memory: 2000000,
},
"test-node-1": &portainer.K8sNodeLimits{
CPU: 1000,
Memory: 3000000,
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
kcl := &KubeClient{
cli: tt.fields.cli,
}
got, err := kcl.GetNodesLimits()
if (err != nil) != tt.wantErr {
t.Errorf("GetNodesLimits() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetNodesLimits() got = %v, want %v", got, tt.want)
}
})
}
}
+118
View File
@@ -0,0 +1,118 @@
package cli
import (
"context"
"fmt"
"log"
"time"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const shellPodImage = "portainer/kubectl-shell"
// CreateUserShellPod will create a kubectl based shell for the specified user by mounting their respective service account.
// The lifecycle of the pod is managed in this function; this entails management of the following pod operations:
// - The shell pod will be scoped to specified service accounts access permissions
// - The shell pod will be automatically removed if it's not ready after specified period of time
// - The shell pod will be automatically removed after a specified max life (prevent zombie pods)
// - The shell pod will be automatically removed if request is cancelled (or client closes websocket connection)
func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountName string) (*portainer.KubernetesShellPod, error) {
maxPodKeepAliveSecondsStr := fmt.Sprintf("%d", int(portainer.WebSocketKeepAlive.Seconds()))
podPrefix := userShellPodPrefix(serviceAccountName)
podSpec := &v1.Pod{
ObjectMeta: metav1.ObjectMeta{
GenerateName: podPrefix,
Namespace: portainerNamespace,
Annotations: map[string]string{
"kubernetes.io/pod.type": "kubectl-shell",
},
},
Spec: v1.PodSpec{
TerminationGracePeriodSeconds: new(int64),
ServiceAccountName: serviceAccountName,
Containers: []v1.Container{
{
Name: "kubectl-shell-container",
Image: shellPodImage,
Command: []string{"sleep"},
// Specify sleep time to prevent zombie pods in case portainer process is terminated
Args: []string{maxPodKeepAliveSecondsStr},
ImagePullPolicy: v1.PullIfNotPresent,
},
},
RestartPolicy: v1.RestartPolicyNever,
},
}
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(podSpec)
if err != nil {
return nil, errors.Wrap(err, "error creating shell pod")
}
// Wait for pod to reach ready state
timeoutCtx, cancelFunc := context.WithTimeout(ctx, 20*time.Second)
defer cancelFunc()
err = kcl.waitForPodStatus(timeoutCtx, v1.PodRunning, shellPod)
if err != nil {
kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil)
return nil, errors.Wrap(err, "aborting pod creation; error waiting for shell pod ready status")
}
if len(shellPod.Spec.Containers) != 1 {
kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil)
return nil, fmt.Errorf("incorrect shell pod state, expecting single container to be present")
}
podData := &portainer.KubernetesShellPod{
Namespace: shellPod.Namespace,
PodName: shellPod.Name,
ContainerName: shellPod.Spec.Containers[0].Name,
ShellExecCommand: "env COLUMNS=200 /bin/bash", // env COLUMNS dictates minimum width of the shell
}
// Handle pod lifecycle/cleanup - terminate pod after maxPodKeepAlive or upon request (long-lived) cancellation
go func() {
select {
case <-time.After(portainer.WebSocketKeepAlive):
log.Println("[DEBUG] [internal,kubernetes/pod] [message: pod removal schedule duration exceeded]")
kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil)
case <-ctx.Done():
err := ctx.Err()
log.Printf("[DEBUG] [internal,kubernetes/pod] [message: context error: err=%s ]\n", err)
kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil)
}
}()
return podData, nil
}
// waitForPodStatus will wait until duration d (from now) for a pod to reach defined phase/status.
// The pod status will be polled at specified delay until the pod reaches ready state.
func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase v1.PodPhase, pod *v1.Pod) error {
log.Printf("[DEBUG] [internal,kubernetes/pod] [message: waiting for pod ready: pod=%s... ]\n", pod.Name)
pollDelay := 500 * time.Millisecond
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(pod.Name, metav1.GetOptions{})
if err != nil {
return err
}
if pod.Status.Phase == phase {
return nil
}
<-time.After(pollDelay)
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package cli
import (
"context"
"testing"
"time"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
func Test_waitForPodStatus(t *testing.T) {
t.Run("successfully errors on cancelled context", func(t *testing.T) {
k := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
}
podSpec := &v1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: defaultNamespace},
Spec: v1.PodSpec{
Containers: []v1.Container{
{Name: "test-pod", Image: "containous/whoami"},
},
},
}
ctx, cancel := context.WithCancel(context.TODO())
cancel()
err := k.waitForPodStatus(ctx, v1.PodRunning, podSpec)
if err != context.Canceled {
t.Errorf("waitForPodStatus should throw context cancellation error; err=%s", err)
}
})
t.Run("successfully errors on timeout", func(t *testing.T) {
k := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
}
podSpec := &v1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: defaultNamespace},
Spec: v1.PodSpec{
Containers: []v1.Container{
{Name: "test-pod", Image: "containous/whoami"},
},
},
}
pod, err := k.cli.CoreV1().Pods(defaultNamespace).Create(podSpec)
if err != nil {
t.Errorf("failed to create pod; err=%s", err)
}
defer k.cli.CoreV1().Pods(defaultNamespace).Delete(pod.Name, nil)
ctx, cancelFunc := context.WithTimeout(context.TODO(), 0*time.Second)
defer cancelFunc()
err = k.waitForPodStatus(ctx, v1.PodRunning, podSpec)
if err != context.DeadlineExceeded {
t.Errorf("waitForPodStatus should throw deadline exceeded error; err=%s", err)
}
})
}
+96
View File
@@ -0,0 +1,96 @@
package cli
import (
"encoding/json"
"fmt"
"strconv"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
v1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
secretDockerConfigKey = ".dockerconfigjson"
)
type (
dockerConfig struct {
Auths map[string]registryDockerConfig `json:"auths"`
}
registryDockerConfig struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
}
)
func (kcl *KubeClient) DeleteRegistrySecret(registry *portainer.Registry, namespace string) error {
err := kcl.cli.CoreV1().Secrets(namespace).Delete(registrySecretName(registry), &metav1.DeleteOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return errors.Wrap(err, "failed removing secret")
}
return nil
}
func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namespace string) error {
config := dockerConfig{
Auths: map[string]registryDockerConfig{
registry.URL: {
Username: registry.Username,
Password: registry.Password,
},
},
}
configByte, err := json.Marshal(config)
if err != nil {
return errors.Wrap(err, "failed marshal config")
}
secret := &v1.Secret{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{
Name: registrySecretName(registry),
Annotations: map[string]string{
"portainer.io/registry.id": strconv.Itoa(int(registry.ID)),
},
},
Data: map[string][]byte{
secretDockerConfigKey: configByte,
},
Type: v1.SecretTypeDockerConfigJson,
}
_, err = kcl.cli.CoreV1().Secrets(namespace).Create(secret)
if err != nil && !k8serrors.IsAlreadyExists(err) {
return errors.Wrap(err, "failed saving secret")
}
return nil
}
func (cli *KubeClient) IsRegistrySecret(namespace, secretName string) (bool, error) {
secret, err := cli.cli.CoreV1().Secrets(namespace).Get(secretName, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
return false, nil
}
return false, err
}
isSecret := secret.Type == v1.SecretTypeDockerConfigJson
return isSecret, nil
}
func registrySecretName(registry *portainer.Registry) string {
return fmt.Sprintf("registry-%d", registry.ID)
}
+27
View File
@@ -0,0 +1,27 @@
package cli
import (
"bytes"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer/json"
)
func GenerateYAML(obj runtime.Object) (string, error) {
serializer := json.NewSerializerWithOptions(
json.DefaultMetaFactory, nil, nil,
json.SerializerOptions{
Yaml: true,
Pretty: true,
Strict: true,
},
)
b := new(bytes.Buffer)
err := serializer.Encode(obj, b)
if err != nil {
return "", err
}
return b.String(), nil
}
+93
View File
@@ -0,0 +1,93 @@
package cli
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/runtime"
clientV1 "k8s.io/client-go/tools/clientcmd/api/v1"
)
// compareYAMLStrings will compare 2 strings by stripping tabs, newlines and whitespaces from both strings
func compareYAMLStrings(in1, in2 string) int {
r := strings.NewReplacer("\t", "", "\n", "", " ", "")
in1 = r.Replace(in1)
in2 = r.Replace(in2)
return strings.Compare(in1, in2)
}
func Test_GenerateYAML(t *testing.T) {
resourceYAMLTests := []struct {
title string
resource runtime.Object
wantYAML string
}{
{
title: "Config",
resource: &clientV1.Config{
APIVersion: "v1",
Kind: "Config",
CurrentContext: "portainer-ctx",
Contexts: []clientV1.NamedContext{
{
Name: "portainer-ctx",
Context: clientV1.Context{
AuthInfo: "test-user",
Cluster: "portainer-cluster",
},
},
},
Clusters: []clientV1.NamedCluster{
{
Name: "portainer-cluster",
Cluster: clientV1.Cluster{
Server: "localhost",
InsecureSkipTLSVerify: true,
},
},
},
AuthInfos: []clientV1.NamedAuthInfo{
{
Name: "test-user",
AuthInfo: clientV1.AuthInfo{
Token: "test-token",
},
},
},
},
wantYAML: `
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: localhost
name: portainer-cluster
contexts:
- context:
cluster: portainer-cluster
user: test-user
name: portainer-ctx
current-context: portainer-ctx
kind: Config
preferences: {}
users:
- name: test-user
user:
token: test-token
`,
},
}
for _, ryt := range resourceYAMLTests {
t.Run(ryt.title, func(t *testing.T) {
yaml, err := GenerateYAML(ryt.resource)
if err != nil {
t.Errorf("generateYamlConfig failed; err=%s", err)
}
if compareYAMLStrings(yaml, ryt.wantYAML) != 0 {
t.Errorf("generateYamlConfig failed;\ngot=\n%s\nwant=\n%s", yaml, ryt.wantYAML)
}
})
}
}
+8 -8
View File
@@ -18,15 +18,10 @@ func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule {
Resources: []string{"storageclasses"},
APIGroups: []string{"storage.k8s.io"},
},
{
Verbs: []string{"list"},
Resources: []string{"ingresses"},
APIGroups: []string{"networking.k8s.io"},
},
}
}
func (kcl *KubeClient) createPortainerUserClusterRole() error {
func (kcl *KubeClient) upsertPortainerK8sClusterRoles() error {
clusterRole := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{
Name: portainerUserCRName,
@@ -35,8 +30,13 @@ func (kcl *KubeClient) createPortainerUserClusterRole() error {
}
_, err := kcl.cli.RbacV1().ClusterRoles().Create(clusterRole)
if err != nil && !k8serrors.IsAlreadyExists(err) {
return err
if err != nil {
if k8serrors.IsAlreadyExists(err) {
_, err = kcl.cli.RbacV1().ClusterRoles().Update(clusterRole)
}
if err != nil {
return err
}
}
return nil
+1 -1
View File
@@ -41,7 +41,7 @@ func (kcl *KubeClient) getServiceAccountToken(serviceAccountName string) (string
}
// API token secret is populated asynchronously.
// Is it created by the controller and will depend on the environment/secret-store:
// Is it created by the controller and will depend on the environment(endpoint)/secret-store:
// https://github.com/kubernetes/kubernetes/issues/67882#issuecomment-422026204
// as a work-around, we wait for up to 5 seconds for the secret to be populated.
timeout := time.After(5 * time.Second)
+27 -17
View File
@@ -1,12 +1,31 @@
package cli
import (
"k8s.io/api/core/v1"
portainer "github.com/portainer/portainer/api"
v1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetServiceAccount returns the portainer ServiceAccountName associated to the specified user.
func (kcl *KubeClient) GetServiceAccount(tokenData *portainer.TokenData) (*v1.ServiceAccount, error) {
var portainerServiceAccountName string
if tokenData.Role == portainer.AdministratorRole {
portainerServiceAccountName = portainerClusterAdminServiceAccountName
} else {
portainerServiceAccountName = userServiceAccountName(int(tokenData.ID), kcl.instanceID)
}
// verify name exists as service account resource within portainer namespace
serviceAccount, err := kcl.cli.CoreV1().ServiceAccounts(portainerNamespace).Get(portainerServiceAccountName, metav1.GetOptions{})
if err != nil {
return nil, err
}
return serviceAccount, nil
}
// GetServiceAccountBearerToken returns the ServiceAccountToken associated to the specified user.
func (kcl *KubeClient) GetServiceAccountBearerToken(userID int) (string, error) {
serviceAccountName := userServiceAccountName(userID, kcl.instanceID)
@@ -17,7 +36,7 @@ func (kcl *KubeClient) GetServiceAccountBearerToken(userID int) (string, error)
// SetupUserServiceAccount will make sure that all the required resources are created inside the Kubernetes
// cluster before creating a ServiceAccount and a ServiceAccountToken for the specified Portainer user.
//It will also create required default RoleBinding and ClusterRoleBinding rules.
func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int) error {
func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error {
serviceAccountName := userServiceAccountName(userID, kcl.instanceID)
err := kcl.ensureRequiredResourcesExist()
@@ -25,20 +44,7 @@ func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int) error
return err
}
err = kcl.ensureServiceAccountForUserExists(serviceAccountName)
if err != nil {
return err
}
return kcl.setupNamespaceAccesses(userID, teamIDs, serviceAccountName)
}
func (kcl *KubeClient) ensureRequiredResourcesExist() error {
return kcl.createPortainerUserClusterRole()
}
func (kcl *KubeClient) ensureServiceAccountForUserExists(serviceAccountName string) error {
err := kcl.createUserServiceAccount(portainerNamespace, serviceAccountName)
err = kcl.createUserServiceAccount(portainerNamespace, serviceAccountName)
if err != nil {
return err
}
@@ -53,7 +59,11 @@ func (kcl *KubeClient) ensureServiceAccountForUserExists(serviceAccountName stri
return err
}
return kcl.ensureNamespaceAccessForServiceAccount(serviceAccountName, defaultNamespace)
return kcl.setupNamespaceAccesses(userID, teamIDs, serviceAccountName, restrictDefaultNamespace)
}
func (kcl *KubeClient) ensureRequiredResourcesExist() error {
return kcl.upsertPortainerK8sClusterRoles()
}
func (kcl *KubeClient) createUserServiceAccount(namespace, serviceAccountName string) error {
@@ -0,0 +1,92 @@
package cli
import (
"testing"
portainer "github.com/portainer/portainer/api"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
func Test_GetServiceAccount(t *testing.T) {
t.Run("returns error if non-existent", func(t *testing.T) {
k := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
}
tokenData := &portainer.TokenData{ID: 1}
_, err := k.GetServiceAccount(tokenData)
if err == nil {
t.Error("GetServiceAccount should fail with service account not found")
}
})
t.Run("succeeds for cluster admin role", func(t *testing.T) {
k := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
}
tokenData := &portainer.TokenData{
ID: 1,
Role: portainer.AdministratorRole,
Username: portainerClusterAdminServiceAccountName,
}
serviceAccount := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: tokenData.Username,
},
}
_, err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount)
if err != nil {
t.Errorf("failed to create service acount; err=%s", err)
}
defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil)
sa, err := k.GetServiceAccount(tokenData)
if err != nil {
t.Errorf("GetServiceAccount should succeed; err=%s", err)
}
want := "portainer-sa-clusteradmin"
if sa.Name != want {
t.Errorf("GetServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want)
}
})
t.Run("succeeds for standard user role", func(t *testing.T) {
k := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "test",
}
tokenData := &portainer.TokenData{
ID: 1,
Role: portainer.StandardUserRole,
}
serviceAccountName := userServiceAccountName(int(tokenData.ID), k.instanceID)
serviceAccount := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: serviceAccountName,
},
}
_, err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount)
if err != nil {
t.Errorf("failed to create service acount; err=%s", err)
}
defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil)
sa, err := k.GetServiceAccount(tokenData)
if err != nil {
t.Errorf("GetServiceAccount should succeed; err=%s", err)
}
want := "portainer-sa-user-test-1"
if sa.Name != want {
t.Errorf("GetServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want)
}
})
}