- Add GetMetrics method to MetricsClient interface and implement cluster metrics API - Add QuotaPrecheck service for validating resource quotas before deployment - Add auth DTO with role/permission models and auth handler tests - Add instance diagnostics: mounted NFS volumes, labels, annotations in pod diagnostics - Update workspace handler with GetWorkspace endpoint and shared-user list - Fix monitoring handler to use correct service method name - Add tail_lines fallback in instance handler for snake_case query params - Update nginx config for SSE log streaming support (no buffering) - Add comprehensive test coverage: auth_service_test, auth_handler_test, auth_dto_test, metrics_client_test, quota_precheck_test - Update error messages for quota validation and instance operations - ModifyModal: fix YAML lineWidth:0, modified keys summary, delta-only submit - InstanceCard: correctly disable scale-minus when replicas <= 0 - SidebarLayout: add hover transition for sidebar items - Update todo.md and lessons.md with latest fixes
215 lines
8.9 KiB
Go
215 lines
8.9 KiB
Go
package k8s
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
authenticationv1 "k8s.io/api/authentication/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
rbacv1 "k8s.io/api/rbac/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/client-go/kubernetes/fake"
|
|
k8stesting "k8s.io/client-go/testing"
|
|
|
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
|
)
|
|
|
|
func TestTenantClientEnsureTenantCreatesResources(t *testing.T) {
|
|
ctx := context.Background()
|
|
clientset := fake.NewSimpleClientset()
|
|
client := NewTenantClientForClientset(clientset)
|
|
binding := tenantBinding()
|
|
|
|
if err := client.EnsureTenant(ctx, nil, binding); err != nil {
|
|
t.Fatalf("EnsureTenant returned error: %v", err)
|
|
}
|
|
|
|
if _, err := clientset.CoreV1().Namespaces().Get(ctx, binding.Namespace, metav1.GetOptions{}); err != nil {
|
|
t.Fatalf("expected namespace: %v", err)
|
|
}
|
|
if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); err != nil {
|
|
t.Fatalf("expected service account: %v", err)
|
|
}
|
|
roleBinding, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("expected role binding: %v", err)
|
|
}
|
|
if roleBinding.RoleRef.Kind != "ClusterRole" || roleBinding.RoleRef.Name != binding.ClusterRoleName {
|
|
t.Fatalf("unexpected role ref: %#v", roleBinding.RoleRef)
|
|
}
|
|
if len(roleBinding.Subjects) != 1 || roleBinding.Subjects[0].Name != binding.ServiceAccountName {
|
|
t.Fatalf("unexpected role binding subjects: %#v", roleBinding.Subjects)
|
|
}
|
|
quota, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("expected resource quota: %v", err)
|
|
}
|
|
if quota.Spec.Hard.Cpu().String() != "2" {
|
|
t.Fatalf("expected cpu quota 2, got %s", quota.Spec.Hard.Cpu().String())
|
|
}
|
|
}
|
|
|
|
func TestTenantClientEnsureTenantUpdatesExistingResources(t *testing.T) {
|
|
ctx := context.Background()
|
|
binding := tenantBinding()
|
|
clientset := fake.NewSimpleClientset(
|
|
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace, Labels: binding.Labels}},
|
|
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}},
|
|
&rbacv1.RoleBinding{
|
|
ObjectMeta: metav1.ObjectMeta{Name: binding.RoleBindingName, Namespace: binding.Namespace},
|
|
RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: "view"},
|
|
},
|
|
&corev1.ResourceQuota{
|
|
ObjectMeta: metav1.ObjectMeta{Name: binding.ResourceQuotaName, Namespace: binding.Namespace},
|
|
Spec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{
|
|
corev1.ResourceCPU: resource.MustParse("1"),
|
|
}},
|
|
},
|
|
)
|
|
client := NewTenantClientForClientset(clientset)
|
|
|
|
if err := client.EnsureTenant(ctx, nil, binding); err != nil {
|
|
t.Fatalf("EnsureTenant returned error: %v", err)
|
|
}
|
|
|
|
roleBinding, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("expected updated role binding: %v", err)
|
|
}
|
|
if roleBinding.RoleRef.Name != binding.ClusterRoleName {
|
|
t.Fatalf("expected role ref %q, got %q", binding.ClusterRoleName, roleBinding.RoleRef.Name)
|
|
}
|
|
if roleBinding.Labels["ocdp.io/tenant"] != binding.Namespace {
|
|
t.Fatalf("expected tenant label on updated role binding, got %#v", roleBinding.Labels)
|
|
}
|
|
quota, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("expected updated quota: %v", err)
|
|
}
|
|
if quota.Spec.Hard.Cpu().String() != "2" {
|
|
t.Fatalf("expected updated cpu quota 2, got %s", quota.Spec.Hard.Cpu().String())
|
|
}
|
|
}
|
|
|
|
func TestTenantClientSuspendTenantDeletesOnlyRoleBinding(t *testing.T) {
|
|
ctx := context.Background()
|
|
binding := tenantBinding()
|
|
clientset := fake.NewSimpleClientset(
|
|
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace, Labels: binding.Labels}},
|
|
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}},
|
|
desiredRoleBinding(binding),
|
|
)
|
|
client := NewTenantClientForClientset(clientset)
|
|
|
|
if err := client.SuspendTenant(ctx, nil, binding); err != nil {
|
|
t.Fatalf("SuspendTenant returned error: %v", err)
|
|
}
|
|
if _, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{}); !apierrors.IsNotFound(err) {
|
|
t.Fatalf("expected deleted role binding, got err %v", err)
|
|
}
|
|
if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); err != nil {
|
|
t.Fatalf("service account should remain: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantClientDeleteTenantDeletesTenantResources(t *testing.T) {
|
|
ctx := context.Background()
|
|
binding := tenantBinding()
|
|
clientset := fake.NewSimpleClientset(
|
|
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace, Labels: binding.Labels}},
|
|
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}},
|
|
desiredRoleBinding(binding),
|
|
&corev1.ResourceQuota{ObjectMeta: metav1.ObjectMeta{Name: binding.ResourceQuotaName, Namespace: binding.Namespace}},
|
|
)
|
|
client := NewTenantClientForClientset(clientset)
|
|
|
|
if err := client.DeleteTenant(ctx, nil, binding); err != nil {
|
|
t.Fatalf("DeleteTenant returned error: %v", err)
|
|
}
|
|
if _, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{}); !apierrors.IsNotFound(err) {
|
|
t.Fatalf("expected role binding deleted, got %v", err)
|
|
}
|
|
if _, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{}); !apierrors.IsNotFound(err) {
|
|
t.Fatalf("expected resource quota deleted, got %v", err)
|
|
}
|
|
if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); !apierrors.IsNotFound(err) {
|
|
t.Fatalf("expected service account deleted, got %v", err)
|
|
}
|
|
if _, err := clientset.CoreV1().Namespaces().Get(ctx, binding.Namespace, metav1.GetOptions{}); !apierrors.IsNotFound(err) {
|
|
t.Fatalf("expected namespace deleted, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantClientDeleteTenantRejectsProtectedNamespace(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := NewTenantClientForClientset(fake.NewSimpleClientset(
|
|
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}},
|
|
))
|
|
binding := entity.NewTenantBinding("default")
|
|
|
|
err := client.DeleteTenant(ctx, nil, binding)
|
|
if !errors.Is(err, entity.ErrProtectedNamespace) {
|
|
t.Fatalf("expected protected namespace error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantClientIssueKubeconfigCapsTokenTTL(t *testing.T) {
|
|
ctx := context.Background()
|
|
binding := tenantBinding()
|
|
clientset := fake.NewSimpleClientset(&corev1.ServiceAccount{
|
|
ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace},
|
|
})
|
|
var requestedExpirationSeconds int64
|
|
expiresAt := time.Now().Add(entity.MaxTenantKubeconfigTTL).UTC()
|
|
clientset.Fake.PrependReactor("create", "serviceaccounts", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
|
if action.GetSubresource() != "token" {
|
|
return false, nil, nil
|
|
}
|
|
createAction := action.(k8stesting.CreateAction)
|
|
tokenRequest := createAction.GetObject().(*authenticationv1.TokenRequest)
|
|
if tokenRequest.Spec.ExpirationSeconds != nil {
|
|
requestedExpirationSeconds = *tokenRequest.Spec.ExpirationSeconds
|
|
}
|
|
return true, &authenticationv1.TokenRequest{
|
|
Status: authenticationv1.TokenRequestStatus{
|
|
Token: "short-lived-token",
|
|
ExpirationTimestamp: metav1.NewTime(expiresAt),
|
|
},
|
|
}, nil
|
|
})
|
|
client := NewTenantClientForClientset(clientset)
|
|
|
|
kubeconfig, err := client.IssueKubeconfig(ctx, &entity.Cluster{Name: "test", Host: "https://example.invalid"}, binding, 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("IssueKubeconfig returned error: %v", err)
|
|
}
|
|
|
|
if requestedExpirationSeconds != int64(entity.MaxTenantKubeconfigTTL.Seconds()) {
|
|
t.Fatalf("expected capped ttl %d, got %d", int64(entity.MaxTenantKubeconfigTTL.Seconds()), requestedExpirationSeconds)
|
|
}
|
|
if !kubeconfig.ExpiresAt.Equal(expiresAt) {
|
|
t.Fatalf("expected expiration %s, got %s", expiresAt, kubeconfig.ExpiresAt)
|
|
}
|
|
if !strings.Contains(kubeconfig.Kubeconfig, "short-lived-token") {
|
|
t.Fatal("expected kubeconfig to contain issued token")
|
|
}
|
|
if !strings.Contains(kubeconfig.Kubeconfig, "namespace: tenant-a") {
|
|
t.Fatalf("expected kubeconfig namespace, got:\n%s", kubeconfig.Kubeconfig)
|
|
}
|
|
}
|
|
|
|
func tenantBinding() entity.TenantBinding {
|
|
binding := entity.NewTenantBinding("tenant-a")
|
|
binding.ResourceQuotaHard = corev1.ResourceList{
|
|
corev1.ResourceCPU: resource.MustParse("2"),
|
|
corev1.ResourceMemory: resource.MustParse("4Gi"),
|
|
}
|
|
return binding
|
|
}
|