refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics
- Add Workspace domain (entity, repository, service, handler, DTO) - Add multi-tenant K8s client with tenant binding and quota management - Add K8s diagnostics client (instance diagnostics) - Add authorization middleware (authz package) - Restructure frontend to feature-based architecture (features/) - Add User Management page in configuration - Add AccessDenied page and route guards - Refactor shared components (form inputs, layout, UI) - Update Tailwind config for new design system - Add comprehensive documentation (docs/, tasks/, plans) - Improve cluster service with better kubeconfig handling - Add tests for crypto, config, helm client, tenant binding
This commit is contained in:
294
backend/internal/adapter/output/k8s/diagnostics_client.go
Normal file
294
backend/internal/adapter/output/k8s/diagnostics_client.go
Normal file
@ -0,0 +1,294 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
type DiagnosticsClient struct{}
|
||||
|
||||
func NewDiagnosticsClient() repository.InstanceDiagnosticsClient {
|
||||
return &DiagnosticsClient{}
|
||||
}
|
||||
|
||||
type MockDiagnosticsClient struct{}
|
||||
|
||||
func NewMockDiagnosticsClient() repository.InstanceDiagnosticsClient {
|
||||
return &MockDiagnosticsClient{}
|
||||
}
|
||||
|
||||
func (*MockDiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error) {
|
||||
return &entity.InstanceDiagnostics{
|
||||
InstanceName: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
CollectedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *DiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error) {
|
||||
clientset, err := diagnosticsClientset(cluster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tailLines <= 0 {
|
||||
tailLines = 200
|
||||
}
|
||||
if tailLines > 2000 {
|
||||
tailLines = 2000
|
||||
}
|
||||
|
||||
pods, err := listInstancePods(ctx, clientset, instance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
services, err := listInstanceServices(ctx, clientset, instance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := listInstanceEvents(ctx, clientset, instance, pods, services)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logs := collectPodLogs(ctx, clientset, pods, tailLines)
|
||||
|
||||
return &entity.InstanceDiagnostics{
|
||||
InstanceName: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
Pods: convertPodsToDiagnostics(pods),
|
||||
Services: convertServicesToDiagnostics(services),
|
||||
Events: convertEventsToDiagnostics(events),
|
||||
Logs: logs,
|
||||
CollectedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func diagnosticsClientset(cluster *entity.Cluster) (kubernetes.Interface, error) {
|
||||
config, err := restConfigFromCluster(cluster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create diagnostics kubernetes client: %w", err)
|
||||
}
|
||||
return clientset, nil
|
||||
}
|
||||
|
||||
func listInstancePods(ctx context.Context, clientset kubernetes.Interface, instance *entity.Instance) ([]corev1.Pod, error) {
|
||||
selector := fmt.Sprintf("app.kubernetes.io/instance=%s", instance.Name)
|
||||
pods, err := clientset.CoreV1().Pods(instance.Namespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instance pods: %w", err)
|
||||
}
|
||||
if len(pods.Items) > 0 {
|
||||
return pods.Items, nil
|
||||
}
|
||||
all, err := clientset.CoreV1().Pods(instance.Namespace).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list namespace pods: %w", err)
|
||||
}
|
||||
filtered := make([]corev1.Pod, 0)
|
||||
for _, pod := range all.Items {
|
||||
if resourceMatchesInstance(pod.ObjectMeta, instance) {
|
||||
filtered = append(filtered, pod)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func listInstanceServices(ctx context.Context, clientset kubernetes.Interface, instance *entity.Instance) ([]corev1.Service, error) {
|
||||
selector := fmt.Sprintf("app.kubernetes.io/instance=%s", instance.Name)
|
||||
services, err := clientset.CoreV1().Services(instance.Namespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instance services: %w", err)
|
||||
}
|
||||
if len(services.Items) > 0 {
|
||||
return services.Items, nil
|
||||
}
|
||||
all, err := clientset.CoreV1().Services(instance.Namespace).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list namespace services: %w", err)
|
||||
}
|
||||
filtered := make([]corev1.Service, 0)
|
||||
for _, svc := range all.Items {
|
||||
if resourceMatchesInstance(svc.ObjectMeta, instance) {
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func listInstanceEvents(ctx context.Context, clientset kubernetes.Interface, instance *entity.Instance, pods []corev1.Pod, services []corev1.Service) ([]corev1.Event, error) {
|
||||
events, err := clientset.CoreV1().Events(instance.Namespace).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instance events: %w", err)
|
||||
}
|
||||
names := map[string]bool{instance.Name: true}
|
||||
for _, pod := range pods {
|
||||
names[pod.Name] = true
|
||||
}
|
||||
for _, svc := range services {
|
||||
names[svc.Name] = true
|
||||
}
|
||||
filtered := make([]corev1.Event, 0)
|
||||
for _, event := range events.Items {
|
||||
if names[event.InvolvedObject.Name] || strings.Contains(event.Message, instance.Name) {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
return filtered[i].LastTimestamp.Time.After(filtered[j].LastTimestamp.Time)
|
||||
})
|
||||
if len(filtered) > 100 {
|
||||
filtered = filtered[:100]
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func collectPodLogs(ctx context.Context, clientset kubernetes.Interface, pods []corev1.Pod, tailLines int64) []entity.InstancePodLog {
|
||||
logs := make([]entity.InstancePodLog, 0)
|
||||
for _, pod := range pods {
|
||||
for _, container := range pod.Spec.Containers {
|
||||
item := entity.InstancePodLog{Pod: pod.Name, Container: container.Name, TailLines: tailLines}
|
||||
req := clientset.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &corev1.PodLogOptions{
|
||||
Container: container.Name,
|
||||
TailLines: &tailLines,
|
||||
})
|
||||
stream, err := req.Stream(ctx)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
logs = append(logs, item)
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(stream, 1<<20))
|
||||
_ = stream.Close()
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
} else {
|
||||
item.Log = string(data)
|
||||
}
|
||||
logs = append(logs, item)
|
||||
}
|
||||
}
|
||||
return logs
|
||||
}
|
||||
|
||||
func convertPodsToDiagnostics(pods []corev1.Pod) []entity.InstancePodDiagnostics {
|
||||
out := make([]entity.InstancePodDiagnostics, 0, len(pods))
|
||||
for _, pod := range pods {
|
||||
containers := make([]entity.InstanceContainerDiagnostics, 0, len(pod.Status.ContainerStatuses))
|
||||
var restarts int32
|
||||
for _, status := range pod.Status.ContainerStatuses {
|
||||
restarts += status.RestartCount
|
||||
containers = append(containers, entity.InstanceContainerDiagnostics{
|
||||
Name: status.Name,
|
||||
Image: status.Image,
|
||||
Ready: status.Ready,
|
||||
RestartCount: status.RestartCount,
|
||||
State: containerStateName(status.State),
|
||||
Reason: containerStateReason(status.State),
|
||||
Message: containerStateMessage(status.State),
|
||||
})
|
||||
}
|
||||
conditions := make([]entity.InstanceConditionDiagnostics, 0, len(pod.Status.Conditions))
|
||||
for _, condition := range pod.Status.Conditions {
|
||||
conditions = append(conditions, entity.InstanceConditionDiagnostics{
|
||||
Type: string(condition.Type),
|
||||
Status: string(condition.Status),
|
||||
Reason: condition.Reason,
|
||||
Message: condition.Message,
|
||||
})
|
||||
}
|
||||
out = append(out, entity.InstancePodDiagnostics{
|
||||
Name: pod.Name,
|
||||
Namespace: pod.Namespace,
|
||||
Phase: string(pod.Status.Phase),
|
||||
NodeName: pod.Spec.NodeName,
|
||||
PodIP: pod.Status.PodIP,
|
||||
HostIP: pod.Status.HostIP,
|
||||
RestartCount: restarts,
|
||||
Containers: containers,
|
||||
Conditions: conditions,
|
||||
CreationTimestamp: pod.CreationTimestamp.Time,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertServicesToDiagnostics(services []corev1.Service) []entity.InstanceServiceDiagnostics {
|
||||
out := make([]entity.InstanceServiceDiagnostics, 0, len(services))
|
||||
for _, svc := range services {
|
||||
entry := convertServiceToEntry(&svc)
|
||||
out = append(out, entity.InstanceServiceDiagnostics{
|
||||
Name: svc.Name,
|
||||
Namespace: svc.Namespace,
|
||||
Type: string(svc.Spec.Type),
|
||||
ClusterIP: svc.Spec.ClusterIP,
|
||||
Ports: entry.Ports,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertEventsToDiagnostics(events []corev1.Event) []entity.InstanceEventDiagnostics {
|
||||
out := make([]entity.InstanceEventDiagnostics, 0, len(events))
|
||||
for _, event := range events {
|
||||
out = append(out, entity.InstanceEventDiagnostics{
|
||||
Type: event.Type,
|
||||
Reason: event.Reason,
|
||||
Message: event.Message,
|
||||
InvolvedKind: event.InvolvedObject.Kind,
|
||||
InvolvedName: event.InvolvedObject.Name,
|
||||
Count: event.Count,
|
||||
FirstTimestamp: event.FirstTimestamp.Time,
|
||||
LastTimestamp: event.LastTimestamp.Time,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containerStateName(state corev1.ContainerState) string {
|
||||
switch {
|
||||
case state.Running != nil:
|
||||
return "running"
|
||||
case state.Waiting != nil:
|
||||
return "waiting"
|
||||
case state.Terminated != nil:
|
||||
return "terminated"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func containerStateReason(state corev1.ContainerState) string {
|
||||
switch {
|
||||
case state.Waiting != nil:
|
||||
return state.Waiting.Reason
|
||||
case state.Terminated != nil:
|
||||
return state.Terminated.Reason
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func containerStateMessage(state corev1.ContainerState) string {
|
||||
switch {
|
||||
case state.Waiting != nil:
|
||||
return state.Waiting.Message
|
||||
case state.Terminated != nil:
|
||||
return state.Terminated.Message
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
388
backend/internal/adapter/output/k8s/tenant_client.go
Normal file
388
backend/internal/adapter/output/k8s/tenant_client.go
Normal file
@ -0,0 +1,388 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// TenantClient provisions namespace-scoped tenant Kubernetes resources.
|
||||
type TenantClient struct {
|
||||
clientset kubernetes.Interface
|
||||
}
|
||||
|
||||
// NewTenantClient creates a tenant provisioning client that builds Kubernetes
|
||||
// clients from the supplied cluster entity for each call.
|
||||
func NewTenantClient() repository.TenantKubeClient {
|
||||
return &TenantClient{}
|
||||
}
|
||||
|
||||
// NewTenantClientForClientset creates a tenant provisioning client for tests or
|
||||
// callers that already own a Kubernetes client.
|
||||
func NewTenantClientForClientset(clientset kubernetes.Interface) repository.TenantKubeClient {
|
||||
return &TenantClient{clientset: clientset}
|
||||
}
|
||||
|
||||
// EnsureTenant idempotently ensures Namespace, ServiceAccount, RoleBinding, and
|
||||
// ResourceQuota resources for the tenant binding.
|
||||
func (c *TenantClient) EnsureTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||
binding = binding.WithDefaults()
|
||||
if err := binding.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
clientset, _, err := c.clientsetForCluster(cluster)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureNamespace(ctx, clientset, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureServiceAccount(ctx, clientset, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureRoleBinding(ctx, clientset, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureResourceQuota(ctx, clientset, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueKubeconfig returns a short-lived kubeconfig backed by a Kubernetes
|
||||
// TokenRequest. The token exists only in the returned value and is never stored.
|
||||
func (c *TenantClient) IssueKubeconfig(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||
binding = binding.WithDefaults()
|
||||
if err := binding.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientset, restConfig, err := c.clientsetForCluster(cluster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cappedTTL := entity.TenantTokenTTL(ttl)
|
||||
expirationSeconds := int64(cappedTTL.Seconds())
|
||||
tokenRequest, err := clientset.CoreV1().
|
||||
ServiceAccounts(binding.Namespace).
|
||||
CreateToken(ctx, binding.ServiceAccountName, &authenticationv1.TokenRequest{
|
||||
Spec: authenticationv1.TokenRequestSpec{
|
||||
ExpirationSeconds: &expirationSeconds,
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request tenant service account token: %w", err)
|
||||
}
|
||||
if tokenRequest.Status.Token == "" {
|
||||
return nil, entity.ErrInvalidTenantKubeconfigToken
|
||||
}
|
||||
|
||||
expiresAt := tokenRequest.Status.ExpirationTimestamp.Time
|
||||
if expiresAt.IsZero() {
|
||||
expiresAt = time.Now().Add(cappedTTL)
|
||||
}
|
||||
kubeconfig, err := buildTenantKubeconfig(cluster, restConfig, binding, tokenRequest.Status.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &entity.TenantKubeconfig{
|
||||
Kubeconfig: kubeconfig,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SuspendTenant revokes tenant API access by deleting only the RoleBinding.
|
||||
func (c *TenantClient) SuspendTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||
binding = binding.WithDefaults()
|
||||
if err := binding.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
clientset, _, err := c.clientsetForCluster(cluster)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = clientset.RbacV1().
|
||||
RoleBindings(binding.Namespace).
|
||||
Delete(ctx, binding.RoleBindingName, metav1.DeleteOptions{})
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete tenant role binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) clientsetForCluster(cluster *entity.Cluster) (kubernetes.Interface, *rest.Config, error) {
|
||||
if c.clientset != nil {
|
||||
config := &rest.Config{Host: "https://kubernetes.default.svc"}
|
||||
if cluster != nil {
|
||||
clusterConfig, err := restConfigFromCluster(cluster)
|
||||
if err == nil {
|
||||
config = clusterConfig
|
||||
}
|
||||
}
|
||||
return c.clientset, config, nil
|
||||
}
|
||||
|
||||
config, err := restConfigFromCluster(cluster)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create tenant kubernetes client: %w", err)
|
||||
}
|
||||
return clientset, config, nil
|
||||
}
|
||||
|
||||
func restConfigFromCluster(cluster *entity.Cluster) (*rest.Config, error) {
|
||||
if cluster == nil {
|
||||
return nil, entity.ErrInvalidClusterHost
|
||||
}
|
||||
if looksLikeKubeconfig(cluster.CAData) {
|
||||
config, err := clientcmd.RESTConfigFromKubeConfig([]byte(cluster.CAData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse tenant kubeconfig: %w", err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
if strings.TrimSpace(cluster.Host) == "" {
|
||||
return nil, entity.ErrInvalidClusterHost
|
||||
}
|
||||
return &rest.Config{
|
||||
Host: cluster.Host,
|
||||
TLSClientConfig: rest.TLSClientConfig{
|
||||
CAData: decodePossiblyBase64(cluster.CAData),
|
||||
CertData: decodePossiblyBase64(cluster.CertData),
|
||||
KeyData: decodePossiblyBase64(cluster.KeyData),
|
||||
},
|
||||
BearerToken: cluster.Token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) ensureNamespace(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||
namespace := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: binding.Namespace,
|
||||
Labels: copyStringMap(binding.Labels),
|
||||
Annotations: copyStringMap(binding.Annotations),
|
||||
},
|
||||
}
|
||||
_, err := clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
current, getErr := clientset.CoreV1().Namespaces().Get(ctx, binding.Namespace, metav1.GetOptions{})
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("failed to get tenant namespace: %w", getErr)
|
||||
}
|
||||
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||
if _, updateErr := clientset.CoreV1().Namespaces().Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||
return fmt.Errorf("failed to update tenant namespace: %w", updateErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tenant namespace: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) ensureServiceAccount(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||
serviceAccount := &corev1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: binding.ServiceAccountName,
|
||||
Namespace: binding.Namespace,
|
||||
Labels: copyStringMap(binding.Labels),
|
||||
Annotations: copyStringMap(binding.Annotations),
|
||||
},
|
||||
}
|
||||
_, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Create(ctx, serviceAccount, metav1.CreateOptions{})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
current, getErr := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{})
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("failed to get tenant service account: %w", getErr)
|
||||
}
|
||||
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||
if _, updateErr := clientset.CoreV1().ServiceAccounts(binding.Namespace).Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||
return fmt.Errorf("failed to update tenant service account: %w", updateErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tenant service account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) ensureRoleBinding(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||
roleBinding := desiredRoleBinding(binding)
|
||||
_, err := clientset.RbacV1().RoleBindings(binding.Namespace).Create(ctx, roleBinding, metav1.CreateOptions{})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
current, getErr := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{})
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("failed to get tenant role binding: %w", getErr)
|
||||
}
|
||||
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||
current.Subjects = roleBinding.Subjects
|
||||
current.RoleRef = roleBinding.RoleRef
|
||||
if _, updateErr := clientset.RbacV1().RoleBindings(binding.Namespace).Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||
return fmt.Errorf("failed to update tenant role binding: %w", updateErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tenant role binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TenantClient) ensureResourceQuota(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||
resourceQuota := &corev1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: binding.ResourceQuotaName,
|
||||
Namespace: binding.Namespace,
|
||||
Labels: copyStringMap(binding.Labels),
|
||||
Annotations: copyStringMap(binding.Annotations),
|
||||
},
|
||||
Spec: corev1.ResourceQuotaSpec{
|
||||
Hard: binding.ResourceQuotaHard.DeepCopy(),
|
||||
},
|
||||
}
|
||||
_, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Create(ctx, resourceQuota, metav1.CreateOptions{})
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
current, getErr := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{})
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("failed to get tenant resource quota: %w", getErr)
|
||||
}
|
||||
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||
current.Spec.Hard = binding.ResourceQuotaHard.DeepCopy()
|
||||
if _, updateErr := clientset.CoreV1().ResourceQuotas(binding.Namespace).Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||
return fmt.Errorf("failed to update tenant resource quota: %w", updateErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tenant resource quota: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func desiredRoleBinding(binding entity.TenantBinding) *rbacv1.RoleBinding {
|
||||
return &rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: binding.RoleBindingName,
|
||||
Namespace: binding.Namespace,
|
||||
Labels: copyStringMap(binding.Labels),
|
||||
Annotations: copyStringMap(binding.Annotations),
|
||||
},
|
||||
Subjects: []rbacv1.Subject{{
|
||||
Kind: rbacv1.ServiceAccountKind,
|
||||
Name: binding.ServiceAccountName,
|
||||
Namespace: binding.Namespace,
|
||||
}},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: rbacv1.GroupName,
|
||||
Kind: "ClusterRole",
|
||||
Name: binding.ClusterRoleName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildTenantKubeconfig(cluster *entity.Cluster, restConfig *rest.Config, binding entity.TenantBinding, token string) (string, error) {
|
||||
host := ""
|
||||
var caData []byte
|
||||
if restConfig != nil {
|
||||
host = restConfig.Host
|
||||
caData = append([]byte{}, restConfig.CAData...)
|
||||
}
|
||||
if host == "" && cluster != nil {
|
||||
host = cluster.Host
|
||||
}
|
||||
if len(caData) == 0 && cluster != nil {
|
||||
caData = decodePossiblyBase64(cluster.CAData)
|
||||
}
|
||||
if host == "" {
|
||||
return "", entity.ErrInvalidClusterHost
|
||||
}
|
||||
|
||||
clusterName := "tenant-cluster"
|
||||
if cluster != nil && cluster.Name != "" {
|
||||
clusterName = cluster.Name
|
||||
}
|
||||
userName := binding.ServiceAccountName
|
||||
contextName := fmt.Sprintf("%s/%s", clusterName, binding.Namespace)
|
||||
config := clientcmdapi.NewConfig()
|
||||
config.Clusters[clusterName] = &clientcmdapi.Cluster{
|
||||
Server: host,
|
||||
CertificateAuthorityData: caData,
|
||||
}
|
||||
config.AuthInfos[userName] = &clientcmdapi.AuthInfo{
|
||||
Token: token,
|
||||
}
|
||||
config.Contexts[contextName] = &clientcmdapi.Context{
|
||||
Cluster: clusterName,
|
||||
AuthInfo: userName,
|
||||
Namespace: binding.Namespace,
|
||||
}
|
||||
config.CurrentContext = contextName
|
||||
|
||||
bytes, err := clientcmd.Write(*config)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build tenant kubeconfig: %w", err)
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func mergeObjectMetadata(meta *metav1.ObjectMeta, labels, annotations map[string]string) {
|
||||
if len(labels) > 0 && meta.Labels == nil {
|
||||
meta.Labels = map[string]string{}
|
||||
}
|
||||
for key, value := range labels {
|
||||
meta.Labels[key] = value
|
||||
}
|
||||
if len(annotations) > 0 && meta.Annotations == nil {
|
||||
meta.Annotations = map[string]string{}
|
||||
}
|
||||
for key, value := range annotations {
|
||||
meta.Annotations[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func copyStringMap(values map[string]string) map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func decodePossiblyBase64(value string) []byte {
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
return []byte(value)
|
||||
}
|
||||
|
||||
func looksLikeKubeconfig(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
return strings.HasPrefix(trimmed, "apiVersion:") || strings.HasPrefix(trimmed, "kind: Config")
|
||||
}
|
||||
172
backend/internal/adapter/output/k8s/tenant_client_test.go
Normal file
172
backend/internal/adapter/output/k8s/tenant_client_test.go
Normal file
@ -0,0 +1,172 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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}},
|
||||
&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}},
|
||||
&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 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
|
||||
}
|
||||
36
backend/internal/adapter/output/k8s/tenant_mock.go
Normal file
36
backend/internal/adapter/output/k8s/tenant_mock.go
Normal file
@ -0,0 +1,36 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
type MockTenantClient struct{}
|
||||
|
||||
func NewMockTenantClient() repository.TenantKubeClient {
|
||||
return &MockTenantClient{}
|
||||
}
|
||||
|
||||
func (c *MockTenantClient) EnsureTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||
return binding.Validate()
|
||||
}
|
||||
|
||||
func (c *MockTenantClient) IssueKubeconfig(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||
if err := binding.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expiresAt := time.Now().Add(entity.TenantTokenTTL(ttl))
|
||||
return &entity.TenantKubeconfig{
|
||||
Kubeconfig: fmt.Sprintf("apiVersion: v1\nkind: Config\nclusters:\n- name: %s\n cluster:\n server: %s\ncontexts:\n- name: %s\n context:\n cluster: %s\n namespace: %s\n user: %s\ncurrent-context: %s\nusers:\n- name: %s\n user:\n token: mock-ephemeral-token\n",
|
||||
cluster.Name, cluster.Host, binding.Namespace, cluster.Name, binding.Namespace, binding.ServiceAccountName, binding.Namespace, binding.ServiceAccountName),
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *MockTenantClient) SuspendTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||
return binding.Validate()
|
||||
}
|
||||
Reference in New Issue
Block a user