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:
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")
|
||||
}
|
||||
Reference in New Issue
Block a user