mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:30:16 +00:00
feat(serviceaccount): service account details view [C9S-36] (#2082)
This commit is contained in:
@@ -124,6 +124,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost)
|
||||
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut)
|
||||
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/service_accounts/{name}", httperror.LoggerHandler(h.getKubernetesServiceAccount)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/volumes", httperror.LoggerHandler(h.GetKubernetesVolumesInNamespace)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet)
|
||||
|
||||
|
||||
@@ -41,6 +41,47 @@ func (handler *Handler) getAllKubernetesServiceAccounts(w http.ResponseWriter, r
|
||||
return response.JSON(w, serviceAccounts)
|
||||
}
|
||||
|
||||
// @id GetKubernetesServiceAccount
|
||||
// @summary Get a kubernetes service account
|
||||
// @description Get a kubernetes service account in the given namespace.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param namespace path string true "Namespace"
|
||||
// @param name path string true "Service account name"
|
||||
// @success 200 {object} models.K8sServiceAccount "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Service account not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/service_accounts/{name} [get]
|
||||
func (handler *Handler) getKubernetesServiceAccount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid namespace", err)
|
||||
}
|
||||
|
||||
name, err := request.RetrieveRouteVariableValue(r, "name")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid name", err)
|
||||
}
|
||||
|
||||
cli, httpErr := handler.prepareKubeClient(r)
|
||||
if httpErr != nil {
|
||||
return httperror.InternalServerError("Unable to prepare kube client", httpErr)
|
||||
}
|
||||
|
||||
sa, err := cli.GetServiceAccount(namespace, name)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve service account", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, sa)
|
||||
}
|
||||
|
||||
// @id DeleteServiceAccounts
|
||||
// @summary Delete service accounts
|
||||
// @description Delete the provided list of service accounts.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
kubeclient "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newServiceAccountTestHandler(t *testing.T) (*Handler, *portainer.User, string) {
|
||||
t.Helper()
|
||||
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
err := store.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: 1,
|
||||
Type: portainer.AgentOnKubernetesEnvironment,
|
||||
})
|
||||
require.NoError(t, err, "error creating environment")
|
||||
|
||||
u := &portainer.User{Username: "admin", Role: portainer.AdministratorRole}
|
||||
err = store.User().Create(u)
|
||||
require.NoError(t, err, "error creating a user")
|
||||
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
require.NoError(t, err, "error initiating jwt service")
|
||||
|
||||
tk, _, err := jwtService.GenerateToken(&portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
|
||||
require.NoError(t, err)
|
||||
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||
|
||||
srvURL, err := url.Parse(srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
cli := testhelpers.NewKubernetesClient()
|
||||
factory, err := kubeclient.NewClientFactory(nil, nil, store, "", ":"+srvURL.Port(), "")
|
||||
require.NoError(t, err)
|
||||
|
||||
authorizationService := authorization.NewService(store)
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, factory, cli)
|
||||
|
||||
return handler, u, tk
|
||||
}
|
||||
|
||||
func newServiceAccountRequest(t *testing.T, method, path string, body []byte, u *portainer.User, tk string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: u.ID, Username: u.Username, Role: u.Role})
|
||||
req = req.WithContext(ctx)
|
||||
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{IsAdmin: true, UserID: u.ID})
|
||||
req = req.WithContext(ctx)
|
||||
testhelpers.AddTestSecurityCookie(req, tk)
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func TestDeleteKubernetesServiceAccounts_ValidPayload(t *testing.T) {
|
||||
handler, u, tk := newServiceAccountTestHandler(t)
|
||||
|
||||
payload := models.K8sServiceAccountDeleteRequests{
|
||||
"default": {"sa-1", "sa-2"},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.NotEqual(t, http.StatusBadRequest, rr.Code, "should not return bad request for valid payload")
|
||||
}
|
||||
|
||||
func TestDeleteKubernetesServiceAccounts_InvalidPayload(t *testing.T) {
|
||||
handler, u, tk := newServiceAccountTestHandler(t)
|
||||
|
||||
payload := models.K8sServiceAccountDeleteRequests{}
|
||||
body, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for invalid payload")
|
||||
bodyData, err := io.ReadAll(rr.Result().Body)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, string(bodyData), "should have error response body")
|
||||
}
|
||||
|
||||
func TestDeleteKubernetesServiceAccounts_EmptyNamespace(t *testing.T) {
|
||||
handler, u, tk := newServiceAccountTestHandler(t)
|
||||
|
||||
payload := models.K8sServiceAccountDeleteRequests{
|
||||
"": {"sa-1"},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := newServiceAccountRequest(t, http.MethodPost, "/kubernetes/1/service_accounts/delete", body, u, tk)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code, "should return bad request for empty namespace")
|
||||
bodyData, err := io.ReadAll(rr.Result().Body)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, string(bodyData), "should have error response body")
|
||||
}
|
||||
@@ -5,16 +5,21 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
type (
|
||||
K8sServiceAccount struct {
|
||||
Name string `json:"name"`
|
||||
UID types.UID `json:"uid"`
|
||||
Namespace string `json:"namespace"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
IsSystem bool `json:"isSystem"`
|
||||
Name string `json:"name"`
|
||||
UID types.UID `json:"uid"`
|
||||
Namespace string `json:"namespace"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
IsSystem bool `json:"isSystem"`
|
||||
ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`
|
||||
AutomountServiceAccountToken *bool `json:"automountServiceAccountToken,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// K8sServiceAcountDeleteRequests is a mapping of namespace names to a slice of service account names.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestK8sServiceAccountDeleteRequests_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload K8sServiceAccountDeleteRequests
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "empty payload returns error",
|
||||
payload: K8sServiceAccountDeleteRequests{},
|
||||
wantErr: true,
|
||||
errMsg: "missing deletion request list in payload",
|
||||
},
|
||||
{
|
||||
name: "valid single namespace with service accounts",
|
||||
payload: K8sServiceAccountDeleteRequests{
|
||||
"default": {"sa-1", "sa-2"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid multiple namespaces",
|
||||
payload: K8sServiceAccountDeleteRequests{
|
||||
"default": {"sa-1"},
|
||||
"kube-system": {"sa-2"},
|
||||
"custom-ns": {"sa-3"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty namespace key returns error",
|
||||
payload: K8sServiceAccountDeleteRequests{
|
||||
"": {"sa-1"},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "deletion given with empty namespace",
|
||||
},
|
||||
{
|
||||
name: "valid with empty service account list",
|
||||
payload: K8sServiceAccountDeleteRequests{
|
||||
"default": {},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple namespaces with one empty returns error",
|
||||
payload: K8sServiceAccountDeleteRequests{
|
||||
"default": {"sa-1"},
|
||||
"": {"sa-2"},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "deletion given with empty namespace",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
err := tt.payload.Validate(req)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestK8sServiceAccount_Structure(t *testing.T) {
|
||||
sa := K8sServiceAccount{
|
||||
Name: "test-sa",
|
||||
Namespace: "default",
|
||||
IsSystem: false,
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-sa", sa.Name)
|
||||
assert.Equal(t, "default", sa.Namespace)
|
||||
assert.False(t, sa.IsSystem)
|
||||
assert.Nil(t, sa.AutomountServiceAccountToken)
|
||||
assert.Empty(t, sa.Labels)
|
||||
assert.Empty(t, sa.Annotations)
|
||||
}
|
||||
|
||||
func TestK8sServiceAccount_WithAllFields(t *testing.T) {
|
||||
automountToken := true
|
||||
sa := K8sServiceAccount{
|
||||
Name: "full-sa",
|
||||
Namespace: "production",
|
||||
IsSystem: true,
|
||||
Labels: map[string]string{
|
||||
"app": "web",
|
||||
"env": "prod",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"description": "service account for web",
|
||||
},
|
||||
AutomountServiceAccountToken: &automountToken,
|
||||
}
|
||||
|
||||
assert.Equal(t, "full-sa", sa.Name)
|
||||
assert.Equal(t, "production", sa.Namespace)
|
||||
assert.True(t, sa.IsSystem)
|
||||
assert.NotNil(t, sa.AutomountServiceAccountToken)
|
||||
assert.True(t, *sa.AutomountServiceAccountToken)
|
||||
assert.Len(t, sa.Labels, 2)
|
||||
assert.Equal(t, "web", sa.Labels["app"])
|
||||
assert.Len(t, sa.Annotations, 1)
|
||||
assert.Equal(t, "service account for web", sa.Annotations["description"])
|
||||
}
|
||||
@@ -61,14 +61,35 @@ func (kcl *KubeClient) fetchServiceAccounts(namespace string) ([]models.K8sServi
|
||||
// parseServiceAccount converts a corev1.ServiceAccount object to a models.K8sServiceAccount object.
|
||||
func (kcl *KubeClient) parseServiceAccount(serviceAccount corev1.ServiceAccount) models.K8sServiceAccount {
|
||||
return models.K8sServiceAccount{
|
||||
Name: serviceAccount.Name,
|
||||
UID: serviceAccount.UID,
|
||||
Namespace: serviceAccount.Namespace,
|
||||
CreationDate: serviceAccount.CreationTimestamp.Time,
|
||||
IsSystem: kcl.isSystemServiceAccount(serviceAccount.Namespace),
|
||||
Name: serviceAccount.Name,
|
||||
UID: serviceAccount.UID,
|
||||
Namespace: serviceAccount.Namespace,
|
||||
CreationDate: serviceAccount.CreationTimestamp.Time,
|
||||
IsSystem: kcl.isSystemServiceAccount(serviceAccount.Namespace),
|
||||
ImagePullSecrets: serviceAccount.ImagePullSecrets,
|
||||
}
|
||||
}
|
||||
|
||||
// GetServiceAccount returns the details of a single service account in the given namespace.
|
||||
func (kcl *KubeClient) GetServiceAccount(namespace, name string) (models.K8sServiceAccount, error) {
|
||||
sa, err := kcl.cli.CoreV1().ServiceAccounts(namespace).Get(context.TODO(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return models.K8sServiceAccount{}, err
|
||||
}
|
||||
|
||||
return models.K8sServiceAccount{
|
||||
Name: sa.Name,
|
||||
UID: sa.UID,
|
||||
Namespace: sa.Namespace,
|
||||
CreationDate: sa.CreationTimestamp.Time,
|
||||
IsSystem: kcl.isSystemServiceAccount(sa.Namespace),
|
||||
AutomountServiceAccountToken: sa.AutomountServiceAccountToken,
|
||||
ImagePullSecrets: sa.ImagePullSecrets,
|
||||
Labels: sa.Labels,
|
||||
Annotations: sa.Annotations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPortainerUserServiceAccount returns the portainer ServiceAccountName associated to the specified user.
|
||||
func (kcl *KubeClient) GetPortainerUserServiceAccount(tokenData *portainer.TokenData) (*corev1.ServiceAccount, error) {
|
||||
portainerUserServiceAccountName := UserServiceAccountName(int(tokenData.ID), kcl.instanceID)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -98,3 +99,131 @@ func Test_GetServiceAccount(t *testing.T) {
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestGetServiceAccountDetails(t *testing.T) {
|
||||
t.Run("returns service account details", func(t *testing.T) {
|
||||
automount := false
|
||||
sa := &v1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-sa",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "web"},
|
||||
},
|
||||
AutomountServiceAccountToken: &automount,
|
||||
ImagePullSecrets: []v1.LocalObjectReference{
|
||||
{Name: "registry-secret"},
|
||||
},
|
||||
}
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(sa),
|
||||
instanceID: "test",
|
||||
}
|
||||
|
||||
result, err := kcl.GetServiceAccount("default", "my-sa")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "my-sa", result.Name)
|
||||
assert.Equal(t, "default", result.Namespace)
|
||||
assert.Equal(t, &automount, result.AutomountServiceAccountToken)
|
||||
assert.Len(t, result.ImagePullSecrets, 1)
|
||||
assert.Equal(t, "registry-secret", result.ImagePullSecrets[0].Name)
|
||||
assert.Equal(t, map[string]string{"app": "web"}, result.Labels)
|
||||
})
|
||||
|
||||
t.Run("returns error when service account not found", func(t *testing.T) {
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(),
|
||||
instanceID: "test",
|
||||
}
|
||||
|
||||
_, err := kcl.GetServiceAccount("default", "does-not-exist")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("marks system namespace accounts as system", func(t *testing.T) {
|
||||
sa := &v1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "kube-system"},
|
||||
}
|
||||
ns := &v1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "kube-system"},
|
||||
}
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(sa, ns),
|
||||
instanceID: "test",
|
||||
}
|
||||
|
||||
result, err := kcl.GetServiceAccount("kube-system", "default")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsSystem)
|
||||
})
|
||||
|
||||
t.Run("returns nil automount when not set", func(t *testing.T) {
|
||||
sa := &v1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "my-sa", Namespace: "default"},
|
||||
}
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(sa),
|
||||
instanceID: "test",
|
||||
}
|
||||
|
||||
result, err := kcl.GetServiceAccount("default", "my-sa")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, result.AutomountServiceAccountToken)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetServiceAccount_CreatesAndFetches(t *testing.T) {
|
||||
t.Run("returns annotations when set", func(t *testing.T) {
|
||||
sa := &v1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "annotated-sa",
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{"example.com/key": "value"},
|
||||
},
|
||||
}
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(sa),
|
||||
instanceID: "test",
|
||||
}
|
||||
|
||||
result, err := kcl.GetServiceAccount("default", "annotated-sa")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"example.com/key": "value"}, result.Annotations)
|
||||
})
|
||||
|
||||
t.Run("round-trips UID correctly", func(t *testing.T) {
|
||||
sa := &v1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "uid-sa",
|
||||
Namespace: "default",
|
||||
UID: "abc-123-def",
|
||||
},
|
||||
}
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(sa),
|
||||
instanceID: "test",
|
||||
}
|
||||
|
||||
result, err := kcl.GetServiceAccount("default", "uid-sa")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "abc-123-def", string(result.UID))
|
||||
})
|
||||
|
||||
t.Run("creates service account and fetches it back", func(t *testing.T) {
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(),
|
||||
instanceID: "test",
|
||||
}
|
||||
|
||||
sa := &v1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "fresh-sa", Namespace: "staging"},
|
||||
}
|
||||
_, err := kcl.cli.CoreV1().ServiceAccounts("staging").Create(context.Background(), sa, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := kcl.GetServiceAccount("staging", "fresh-sa")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "fresh-sa", result.Name)
|
||||
assert.Equal(t, "staging", result.Namespace)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -646,6 +646,19 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
},
|
||||
};
|
||||
|
||||
const serviceAccount = {
|
||||
name: 'kubernetes.moreResources.serviceAccounts.serviceAccount',
|
||||
url: '/serviceAccounts/:namespace/:name?tab',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'serviceAccountView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/more-resources/service-accounts',
|
||||
},
|
||||
};
|
||||
|
||||
const clusterRoles = {
|
||||
name: 'kubernetes.moreResources.clusterRoles',
|
||||
url: '/clusterRoles?tab',
|
||||
@@ -717,6 +730,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
$stateRegistryProvider.register(moreResources);
|
||||
$stateRegistryProvider.register(jobs);
|
||||
$stateRegistryProvider.register(serviceAccounts);
|
||||
$stateRegistryProvider.register(serviceAccount);
|
||||
$stateRegistryProvider.register(clusterRoles);
|
||||
$stateRegistryProvider.register(roles);
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsV
|
||||
import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView';
|
||||
import { NamespacesView } from '@/react/kubernetes/namespaces/ListView/NamespacesView';
|
||||
import { ServiceAccountsView } from '@/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsView';
|
||||
import { ServiceAccountView } from '@/react/kubernetes/more-resources/ServiceAccountsView/ItemView/ServiceAccountView';
|
||||
import { ClusterRolesView } from '@/react/kubernetes/more-resources/ClusterRolesView';
|
||||
import { RolesView } from '@/react/kubernetes/more-resources/RolesView';
|
||||
import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
|
||||
@@ -123,6 +124,10 @@ export const viewsModule = angular
|
||||
'serviceAccountsView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ServiceAccountsView))), [])
|
||||
)
|
||||
.component(
|
||||
'serviceAccountView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ServiceAccountView))), [])
|
||||
)
|
||||
.component(
|
||||
'clusterRolesView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterRolesView))), [])
|
||||
|
||||
@@ -60,8 +60,8 @@ class KubernetesSecretController {
|
||||
}
|
||||
|
||||
getRegistryId() {
|
||||
const annotation = this.configuration?.Annotations?.find((a) => a.key === 'portainer.io/registry.id');
|
||||
return annotation ? parseInt(annotation.value, 10) || undefined : undefined;
|
||||
const id = this.configuration?.Annotations?.['portainer.io/registry.id'];
|
||||
return id ? parseInt(id, 10) || undefined : undefined;
|
||||
}
|
||||
|
||||
isSystemConfig() {
|
||||
|
||||
@@ -3,10 +3,11 @@ import { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
colClassName?: string;
|
||||
className?: string;
|
||||
columns?: Array<ReactNode>;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function DetailsRow({
|
||||
@@ -15,12 +16,12 @@ export function DetailsRow({
|
||||
colClassName,
|
||||
className,
|
||||
columns,
|
||||
ariaLabel,
|
||||
}: Props) {
|
||||
const labelString = typeof label === 'string' ? label : undefined;
|
||||
return (
|
||||
<tr className={className} aria-label={label}>
|
||||
<td className={clsx(colClassName, 'min-w-[150px] !break-normal')}>
|
||||
{label}
|
||||
</td>
|
||||
<tr className={className} aria-label={ariaLabel ?? labelString}>
|
||||
<td className={clsx(colClassName, '!break-normal')}>{label}</td>
|
||||
<td className={colClassName} data-cy={`detailsTable-${label}Value`}>
|
||||
{children}
|
||||
</td>
|
||||
|
||||
@@ -17,6 +17,7 @@ export function useSecrets(environmentId: EnvironmentId, namespace?: string) {
|
||||
{
|
||||
...withGlobalError(`Unable to get secrets in namespace '${namespace}'`),
|
||||
enabled: !!namespace,
|
||||
retry: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useGetAllServiceAccountsQuery } from '@/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useGetAllServiceAccountsQuery';
|
||||
|
||||
import { Badge } from '@@/Badge';
|
||||
import { SystemBadge } from '@@/Badge/SystemBadge';
|
||||
import { DetailsRow } from '@@/DetailsTable/DetailsRow';
|
||||
import { DetailsTable } from '@@/DetailsTable/DetailsTable';
|
||||
@@ -56,6 +60,79 @@ export function SecretDetailsTable({
|
||||
</RegistryBadge>
|
||||
</DetailsRow>
|
||||
)}
|
||||
<LinkedServiceAccountsRow secretName={name} namespace={namespace} />
|
||||
</DetailsTable>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_SERVICE_ACCOUNTS = 5;
|
||||
|
||||
type LinkedServiceAccountsRowProps = {
|
||||
secretName: string;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
function LinkedServiceAccountsRow({
|
||||
secretName,
|
||||
namespace,
|
||||
}: LinkedServiceAccountsRowProps) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: allServiceAccounts = [] } =
|
||||
useGetAllServiceAccountsQuery(environmentId);
|
||||
|
||||
const linked = allServiceAccounts.filter(
|
||||
(sa) =>
|
||||
sa.namespace === namespace &&
|
||||
sa.imagePullSecrets?.some((s) => s.name === secretName)
|
||||
);
|
||||
|
||||
const visible = linked.slice(0, MAX_VISIBLE_SERVICE_ACCOUNTS);
|
||||
const hidden = linked.slice(MAX_VISIBLE_SERVICE_ACCOUNTS);
|
||||
|
||||
return (
|
||||
<DetailsRow
|
||||
label={
|
||||
<span className="flex items-center">
|
||||
Linked service accounts
|
||||
<Tooltip message="Service accounts that use this secret as an image pull secret." />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visible.length > 0 ? (
|
||||
<>
|
||||
{visible.map((sa) => (
|
||||
<Badge key={sa.uid} type="info" className="min-w-max">
|
||||
<Link
|
||||
to="kubernetes.moreResources.serviceAccounts.serviceAccount"
|
||||
params={{ namespace: sa.namespace, name: sa.name }}
|
||||
data-cy={`linked-service-account-link-${sa.name}`}
|
||||
className="!text-inherit"
|
||||
>
|
||||
{sa.name}
|
||||
</Link>
|
||||
</Badge>
|
||||
))}
|
||||
{hidden.length > 0 && (
|
||||
<Badge type="muted" className="min-w-max cursor-default">
|
||||
+ {hidden.length} more
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted">
|
||||
None - Link{' '}
|
||||
<Link
|
||||
to="kubernetes.moreResources.serviceAccounts"
|
||||
data-cy="service-account-link"
|
||||
>
|
||||
service accounts
|
||||
</Link>{' '}
|
||||
to this secret by referencing it in the{' '}
|
||||
<code>imagePullSecrets</code> field in the service account spec.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DetailsRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRegistry } from '@/react/portainer/registries/queries/useRegistry';
|
||||
import { Badge } from '@@/Badge';
|
||||
import { Link } from '@@/Link';
|
||||
import { InlineLoader } from '@@/InlineLoader/InlineLoader';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
type Props = {
|
||||
registryId: number;
|
||||
@@ -20,7 +21,12 @@ export function RegistryBadge({ registryId, children, dataCy }: Props) {
|
||||
}
|
||||
|
||||
if (registryQuery.isError || !registryQuery.data) {
|
||||
return <Badge type="warn">Registry deleted</Badge>;
|
||||
return (
|
||||
<Badge type="warn">
|
||||
Registry not found
|
||||
<Tooltip message="The registry associated with this secret could not be found. It may have been deleted." />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const { Name } = registryQuery.data;
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { useServiceAccount } from '../queries/useServiceAccount';
|
||||
|
||||
import { ServiceAccountDetailsWidget } from './ServiceAccountDetailsWidget';
|
||||
|
||||
vi.mock('../queries/useServiceAccount', () => ({
|
||||
useServiceAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||
useEnvironmentId: () => 1,
|
||||
}));
|
||||
|
||||
vi.mock('@/react/hooks/useUser', () => ({
|
||||
useCurrentUser: () => ({ isPureAdmin: true }),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/kubernetes/configs/queries/useSecrets', () => ({
|
||||
useSecrets: vi.fn(() => ({ data: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/portainer/registries/queries/useRegistry', () => ({
|
||||
useRegistry: vi.fn(() => ({ data: undefined })),
|
||||
}));
|
||||
|
||||
vi.mock('@@/Link', () => ({
|
||||
Link: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
function renderWidget() {
|
||||
const client = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ServiceAccountDetailsWidget namespace="default" name="my-sa" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ServiceAccountDetailsWidget', () => {
|
||||
it('shows service account name and namespace', () => {
|
||||
vi.mocked(useServiceAccount).mockReturnValue({
|
||||
data: {
|
||||
name: 'my-sa',
|
||||
namespace: 'default',
|
||||
isSystem: false,
|
||||
uid: '',
|
||||
creationDate: '',
|
||||
},
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useServiceAccount>);
|
||||
|
||||
renderWidget();
|
||||
expect(screen.getByText('my-sa')).toBeInTheDocument();
|
||||
expect(screen.getByText('default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows image pull secret badges', () => {
|
||||
vi.mocked(useServiceAccount).mockReturnValue({
|
||||
data: {
|
||||
name: 'my-sa',
|
||||
namespace: 'default',
|
||||
isSystem: false,
|
||||
uid: '',
|
||||
creationDate: '',
|
||||
imagePullSecrets: [{ name: 'registry-creds' }],
|
||||
},
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useServiceAccount>);
|
||||
|
||||
renderWidget();
|
||||
expect(screen.getByText('registry-creds')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "None" when there are no image pull secrets', () => {
|
||||
vi.mocked(useServiceAccount).mockReturnValue({
|
||||
data: {
|
||||
name: 'my-sa',
|
||||
namespace: 'default',
|
||||
isSystem: false,
|
||||
uid: '',
|
||||
creationDate: '',
|
||||
imagePullSecrets: [],
|
||||
},
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useServiceAccount>);
|
||||
|
||||
renderWidget();
|
||||
expect(screen.getByText('None')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates image pull secrets beyond the visible limit and shows overflow badge', () => {
|
||||
const secrets = Array.from({ length: 7 }, (_, i) => ({
|
||||
name: `secret-${i}`,
|
||||
}));
|
||||
vi.mocked(useServiceAccount).mockReturnValue({
|
||||
data: {
|
||||
name: 'my-sa',
|
||||
namespace: 'default',
|
||||
isSystem: false,
|
||||
uid: '',
|
||||
creationDate: '',
|
||||
imagePullSecrets: secrets,
|
||||
},
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useServiceAccount>);
|
||||
|
||||
renderWidget();
|
||||
expect(screen.getByText('+ 2 more')).toBeInTheDocument();
|
||||
expect(screen.queryByText('secret-5')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows automount token as Disabled when explicitly false', () => {
|
||||
vi.mocked(useServiceAccount).mockReturnValue({
|
||||
data: {
|
||||
name: 'my-sa',
|
||||
namespace: 'default',
|
||||
isSystem: false,
|
||||
uid: '',
|
||||
creationDate: '',
|
||||
automountServiceAccountToken: false,
|
||||
},
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useServiceAccount>);
|
||||
|
||||
renderWidget();
|
||||
expect(screen.getByText('Disabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows automount token as Enabled when not set', () => {
|
||||
vi.mocked(useServiceAccount).mockReturnValue({
|
||||
data: {
|
||||
name: 'my-sa',
|
||||
namespace: 'default',
|
||||
isSystem: false,
|
||||
uid: '',
|
||||
creationDate: '',
|
||||
},
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useServiceAccount>);
|
||||
|
||||
renderWidget();
|
||||
expect(screen.getByText('Enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows SystemBadge for system service accounts', () => {
|
||||
vi.mocked(useServiceAccount).mockReturnValue({
|
||||
data: {
|
||||
name: 'default',
|
||||
namespace: 'kube-system',
|
||||
isSystem: true,
|
||||
uid: '',
|
||||
creationDate: '',
|
||||
},
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useServiceAccount>);
|
||||
|
||||
renderWidget();
|
||||
const systemBadges = screen.getAllByRole('status');
|
||||
expect(systemBadges).toHaveLength(2);
|
||||
expect(systemBadges[0]).toHaveTextContent('System');
|
||||
});
|
||||
});
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
import { Secret } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useSecrets } from '@/react/kubernetes/configs/queries/useSecrets';
|
||||
import { useRegistry } from '@/react/portainer/registries/queries/useRegistry';
|
||||
|
||||
import { Badge } from '@@/Badge';
|
||||
import { SystemBadge } from '@@/Badge/SystemBadge';
|
||||
import { DetailsRow } from '@@/DetailsTable/DetailsRow';
|
||||
import { DetailsTable } from '@@/DetailsTable/DetailsTable';
|
||||
import { Link } from '@@/Link';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
|
||||
import { useServiceAccount } from '../queries/useServiceAccount';
|
||||
|
||||
type Props = { namespace: string; name: string };
|
||||
|
||||
export function ServiceAccountDetailsWidget({ namespace, name }: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const serviceAccountQuery = useServiceAccount(environmentId, namespace, name);
|
||||
const { data: serviceAccount } = serviceAccountQuery;
|
||||
const { isLoading } = serviceAccountQuery;
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody loading={isLoading}>
|
||||
<DetailsTable
|
||||
dataCy="k8sSADetail-table"
|
||||
className="[&_td:first-child]:w-2/5"
|
||||
>
|
||||
<DetailsRow label="Name">
|
||||
{serviceAccount?.name}{' '}
|
||||
{serviceAccount?.isSystem && <SystemBadge />}
|
||||
</DetailsRow>
|
||||
<DetailsRow label="Namespace">
|
||||
<Link
|
||||
to="kubernetes.resourcePools.resourcePool"
|
||||
params={{ id: namespace }}
|
||||
data-cy="namespace-link"
|
||||
>
|
||||
{namespace}
|
||||
</Link>
|
||||
{serviceAccount?.isSystem && <SystemBadge />}
|
||||
</DetailsRow>
|
||||
<DetailsRow label="Creation date">
|
||||
{serviceAccount?.creationDate
|
||||
? new Date(serviceAccount.creationDate).toLocaleString()
|
||||
: '-'}
|
||||
</DetailsRow>
|
||||
<DetailsRow label="Automount token">
|
||||
<span className="flex items-center">
|
||||
{serviceAccount?.automountServiceAccountToken === false
|
||||
? 'Disabled'
|
||||
: 'Enabled'}
|
||||
<Tooltip message="Controls whether pods automatically receive an API token for cluster access. Disabling this reduces attack surface for workloads that don't need Kubernetes API access. Individual pods can still override this setting." />
|
||||
</span>
|
||||
</DetailsRow>
|
||||
|
||||
<ImagePullSecretsRow
|
||||
namespace={namespace}
|
||||
name={name}
|
||||
imagePullSecrets={serviceAccount?.imagePullSecrets ?? []}
|
||||
/>
|
||||
</DetailsTable>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_SECRETS = 5;
|
||||
|
||||
type ImagePullSecretName = { name: string };
|
||||
|
||||
type ImagePullSecretsRowProps = {
|
||||
namespace: string;
|
||||
name: string;
|
||||
imagePullSecrets: ImagePullSecretName[];
|
||||
};
|
||||
|
||||
function ImagePullSecretsRow({
|
||||
namespace,
|
||||
name,
|
||||
imagePullSecrets,
|
||||
}: ImagePullSecretsRowProps) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: secrets = [] } = useSecrets(environmentId, namespace);
|
||||
|
||||
const pullSecretNamesToSecrets = linkImagePullSecretsToSecrets(
|
||||
secrets,
|
||||
imagePullSecrets
|
||||
);
|
||||
|
||||
const visibleSecrets = pullSecretNamesToSecrets.slice(0, MAX_VISIBLE_SECRETS);
|
||||
const hiddenSecrets = pullSecretNamesToSecrets.slice(MAX_VISIBLE_SECRETS);
|
||||
|
||||
return (
|
||||
<DetailsRow
|
||||
label={
|
||||
<span className="flex items-center">
|
||||
Image pull secrets
|
||||
<Tooltip
|
||||
message={
|
||||
name === 'default' ? (
|
||||
<>
|
||||
<code>imagePullSecrets</code> from this 'default'
|
||||
service account apply to all <strong>pods</strong> without an
|
||||
explicit service account in this namespace.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
These <code>imagePullSecrets</code> are inherited by pods
|
||||
using this service account.
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="mt-2 flex flex-col">
|
||||
{visibleSecrets.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{visibleSecrets.map((s) => (
|
||||
<ImagePullSecretBadge
|
||||
key={s.name}
|
||||
secretName={s.name}
|
||||
namespace={namespace}
|
||||
secret={s.secret}
|
||||
/>
|
||||
))}
|
||||
{hiddenSecrets.length > 0 && (
|
||||
<TooltipWithChildren
|
||||
message={hiddenSecrets.map((s) => s.name).join(', ')}
|
||||
>
|
||||
<span>
|
||||
<Badge type="muted" className="min-w-max cursor-default">
|
||||
+ {hiddenSecrets.length} more
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted">None</span>
|
||||
)}
|
||||
</div>
|
||||
</DetailsRow>
|
||||
);
|
||||
}
|
||||
|
||||
function linkImagePullSecretsToSecrets(
|
||||
secrets: Secret[],
|
||||
secretNames: { name: string }[]
|
||||
) {
|
||||
return secretNames.map(({ name }) => {
|
||||
const secret = secrets.find((s) => s.metadata?.name === name);
|
||||
return { name, secret };
|
||||
});
|
||||
}
|
||||
|
||||
type ImagePullSecretBadgeProps = {
|
||||
secretName: string;
|
||||
namespace: string;
|
||||
secret: Secret | undefined;
|
||||
};
|
||||
|
||||
function ImagePullSecretBadge({
|
||||
secretName,
|
||||
namespace,
|
||||
secret,
|
||||
}: ImagePullSecretBadgeProps) {
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
const registryIdStr =
|
||||
secret?.metadata?.annotations?.['portainer.io/registry.id'];
|
||||
const registryId = registryIdStr
|
||||
? parseInt(registryIdStr, 10) || undefined
|
||||
: undefined;
|
||||
const { data: registry, isLoading: isRegistryLoading } =
|
||||
useRegistry(registryId);
|
||||
|
||||
const registryTooltip = registry && (
|
||||
<Tooltip
|
||||
position="right"
|
||||
message={
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
<span className="font-medium">Registry: </span>
|
||||
{isPureAdmin ? (
|
||||
<Link
|
||||
to="portainer.registries.registry"
|
||||
params={{ id: registry.Id }}
|
||||
className="!text-inherit underline"
|
||||
data-cy={`registry-link-${registry.Id}`}
|
||||
>
|
||||
{registry.Name}
|
||||
</Link>
|
||||
) : (
|
||||
registry.Name
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-medium">URL: </span>
|
||||
{registry.URL}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const registryNotFoundMessage =
|
||||
'The registry associated with this secret could not be found. It may have been deleted.';
|
||||
const showRegistryNotFound = !!registryId && !isRegistryLoading && !registry;
|
||||
|
||||
const secretLink = (
|
||||
<Link
|
||||
to="kubernetes.secrets.secret"
|
||||
params={{ name: secretName, namespace }}
|
||||
data-cy={`image-pull-secret-link-${secretName}`}
|
||||
className="!text-inherit"
|
||||
>
|
||||
{secretName}
|
||||
</Link>
|
||||
);
|
||||
|
||||
const missingSecretContent = (
|
||||
<>
|
||||
{secretName}
|
||||
<Tooltip message="This secret doesn't exist in the namespace." />
|
||||
</>
|
||||
);
|
||||
|
||||
function renderSecretName() {
|
||||
const content = secret ? secretLink : missingSecretContent;
|
||||
if (showRegistryNotFound) {
|
||||
return (
|
||||
<TooltipWithChildren message={registryNotFoundMessage}>
|
||||
<span>{content}</span>
|
||||
</TooltipWithChildren>
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
type={secret ? 'info' : 'warn'}
|
||||
className="inline-flex items-center min-w-max"
|
||||
>
|
||||
{renderSecretName()}
|
||||
{registryTooltip}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { ServiceAccountView } from './ServiceAccountView';
|
||||
|
||||
let mockParams: {
|
||||
endpointId: number;
|
||||
namespace: string;
|
||||
name: string;
|
||||
tab?: string;
|
||||
} = { endpointId: 1, namespace: 'default', name: 'my-sa' };
|
||||
|
||||
vi.mock('@@/Link', () => ({
|
||||
Link: ({
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
[key: string]: unknown;
|
||||
}) => <a {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as object),
|
||||
useCurrentStateAndParams: () => ({ params: mockParams }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./ServiceAccountDetailsWidget', () => ({
|
||||
ServiceAccountDetailsWidget: () => (
|
||||
<div data-testid="sa-details">Details Widget</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ServiceAccountYAMLEditor', () => ({
|
||||
ServiceAccountYAMLEditor: () => (
|
||||
<div data-testid="yaml-view">YAML Editor</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||
useEnvironmentId: () => 1,
|
||||
}));
|
||||
|
||||
function getWrapped() {
|
||||
const user = new UserViewModel({ Username: 'admin' });
|
||||
const routerConfig = [{ name: 'root', url: '/' }];
|
||||
return withTestQueryProvider(
|
||||
withUserProvider(
|
||||
withTestRouter(ServiceAccountView, {
|
||||
route: 'root',
|
||||
stateConfig: routerConfig,
|
||||
}),
|
||||
user
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
describe('ServiceAccountView', () => {
|
||||
beforeEach(() => {
|
||||
mockParams = { endpointId: 1, namespace: 'default', name: 'my-sa' };
|
||||
});
|
||||
|
||||
it('renders with page title', () => {
|
||||
const Wrapped = getWrapped();
|
||||
render(<Wrapped />);
|
||||
expect(screen.getByText('Service account details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders service account name in breadcrumb', () => {
|
||||
const Wrapped = getWrapped();
|
||||
render(<Wrapped />);
|
||||
expect(screen.getByText('my-sa')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { Code, User } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { WidgetTabs, useCurrentTabIndex, Tab } from '@@/Widget/WidgetTabs';
|
||||
|
||||
import { ServiceAccountDetailsWidget } from './ServiceAccountDetailsWidget';
|
||||
import { ServiceAccountYAMLEditor } from './ServiceAccountYAMLEditor';
|
||||
|
||||
export function ServiceAccountView() {
|
||||
const {
|
||||
params: { namespace, name },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
name: 'Service account',
|
||||
icon: User,
|
||||
widget: <ServiceAccountDetailsWidget namespace={namespace} name={name} />,
|
||||
selectedTabParam: 'service-account',
|
||||
},
|
||||
{
|
||||
name: 'YAML',
|
||||
icon: Code,
|
||||
widget: <ServiceAccountYAMLEditor />,
|
||||
selectedTabParam: 'YAML',
|
||||
},
|
||||
];
|
||||
|
||||
const currentTabIndex = useCurrentTabIndex(tabs);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Service account details"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: 'Service accounts',
|
||||
link: 'kubernetes.moreResources.serviceAccounts',
|
||||
},
|
||||
{
|
||||
label: namespace,
|
||||
link: 'kubernetes.resourcePools.resourcePool',
|
||||
linkParams: { id: namespace },
|
||||
},
|
||||
name,
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
<WidgetTabs tabs={tabs} currentTabIndex={currentTabIndex} />
|
||||
{tabs[currentTabIndex].widget}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import axios from '@/portainer/services/axios/axios';
|
||||
import { parseKubernetesAxiosError } from '@/react/kubernetes/axiosError';
|
||||
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
import { YAMLInspector } from '../../../components/YAMLInspector';
|
||||
|
||||
export function ServiceAccountYAMLEditor() {
|
||||
const {
|
||||
params: { namespace, name },
|
||||
} = useCurrentStateAndParams();
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const {
|
||||
data = '',
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery(
|
||||
[environmentId, 'kubernetes', 'serviceaccount-yaml', namespace, name],
|
||||
() => getServiceAccountYAML(environmentId, namespace, name),
|
||||
{ enabled: !!namespace && !!name }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<YAMLInspector
|
||||
identifier={`serviceaccount-${namespace}-${name}`}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
data-cy="serviceaccount-yaml"
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function getServiceAccountYAML(
|
||||
environmentId: number,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<string>(
|
||||
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/serviceaccounts/${name}`,
|
||||
{ headers: { Accept: 'application/yaml' } }
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(
|
||||
e,
|
||||
'Unable to retrieve service account YAML'
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -1,4 +1,5 @@
|
||||
import { SystemBadge } from '@@/Badge/SystemBadge';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
@@ -15,7 +16,16 @@ export const name = columnHelper.accessor(
|
||||
id: 'name',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<div>{row.original.name}</div>
|
||||
<Link
|
||||
to="kubernetes.moreResources.serviceAccounts.serviceAccount"
|
||||
params={{
|
||||
namespace: row.original.namespace,
|
||||
name: row.original.name,
|
||||
}}
|
||||
data-cy={`sa-name-link-${row.original.name}`}
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
{row.original.isSystem && <SystemBadge className="ml-auto" />}
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export const queryKeys = {
|
||||
base: (environmentId: EnvironmentId) =>
|
||||
['environments', environmentId, 'kubernetes', 'serviceaccounts'] as const,
|
||||
detail: (environmentId: EnvironmentId, namespace: string, name: string) =>
|
||||
[queryKeys.base(environmentId), namespace, name] as const,
|
||||
yaml: (environmentId: EnvironmentId, namespace: string, name: string) =>
|
||||
[queryKeys.base(environmentId), namespace, name, 'yaml'] as const,
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { HttpResponse } from 'msw';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { server, http } from '@/setup-tests/server';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
|
||||
|
||||
import { useServiceAccount } from './useServiceAccount';
|
||||
|
||||
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||
useEnvironmentId: () => 1,
|
||||
}));
|
||||
|
||||
function renderQueryHook(namespace: string, name: string) {
|
||||
return renderHook(() => useServiceAccount(1, namespace, name), {
|
||||
wrapper: withTestQueryProvider(({ children }) => <>{children}</>),
|
||||
});
|
||||
}
|
||||
|
||||
describe('useServiceAccount', () => {
|
||||
it('returns service account data on success', async () => {
|
||||
const mockSA = { name: 'my-sa', namespace: 'default', uid: 'abc-123' };
|
||||
server.use(
|
||||
http.get(
|
||||
'/api/kubernetes/1/namespaces/default/service_accounts/my-sa',
|
||||
() => HttpResponse.json(mockSA)
|
||||
)
|
||||
);
|
||||
|
||||
const { result } = renderQueryHook('default', 'my-sa');
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.name).toBe('my-sa');
|
||||
});
|
||||
|
||||
it('sets isError on a 500 response', async () => {
|
||||
const restoreConsole = suppressConsoleLogs();
|
||||
server.use(
|
||||
http.get(
|
||||
'/api/kubernetes/1/namespaces/default/service_accounts/bad',
|
||||
() => HttpResponse.json({ message: 'error' }, { status: 500 })
|
||||
)
|
||||
);
|
||||
|
||||
const { result } = renderQueryHook('default', 'bad');
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
restoreConsole();
|
||||
});
|
||||
|
||||
it('does not fetch when name is empty', async () => {
|
||||
const { result } = renderQueryHook('default', '');
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fetch when namespace is empty', async () => {
|
||||
const { result } = renderQueryHook('', 'my-sa');
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export type ServiceAccountDetails = {
|
||||
name: string;
|
||||
uid: string;
|
||||
namespace: string;
|
||||
creationDate: string;
|
||||
isSystem: boolean;
|
||||
automountServiceAccountToken?: boolean;
|
||||
imagePullSecrets?: Array<{ name: string }>;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
|
||||
export function useServiceAccount(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.detail(environmentId, namespace, name),
|
||||
async () => getServiceAccount(environmentId, namespace, name),
|
||||
{
|
||||
enabled: !!environmentId && !!namespace && !!name,
|
||||
...withGlobalError('Unable to get service account'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getServiceAccount(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
name: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<ServiceAccountDetails>(
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}/service_accounts/${name}`
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to get service account');
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@ export type ServiceAccount = {
|
||||
namespace: string;
|
||||
creationDate: string;
|
||||
isSystem: boolean;
|
||||
imagePullSecrets?: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export function useRegistry(registryId?: Registry['Id']) {
|
||||
() => (registryId ? getRegistry(registryId, environmentId) : undefined),
|
||||
{
|
||||
enabled: !!registryId,
|
||||
retry: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user