Merge branch 'release/2.9'
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user