diff --git a/apps/api/src/pkg/testutil/harness.ts b/apps/api/src/pkg/testutil/harness.ts index c4d75c6f9c..36a45c4c29 100644 --- a/apps/api/src/pkg/testutil/harness.ts +++ b/apps/api/src/pkg/testutil/harness.ts @@ -269,6 +269,7 @@ export abstract class Harness { updatedAtM: null, deletedAtM: null, partitionId: null, + k8sNamespace: null, }; const userWorkspace: Workspace = { id: newId("test"), @@ -288,6 +289,7 @@ export abstract class Harness { updatedAtM: null, deletedAtM: null, partitionId: null, + k8sNamespace: null, }; const unkeyKeyAuth: KeyAuth = { diff --git a/apps/dashboard/lib/trpc/routers/workspace/create.ts b/apps/dashboard/lib/trpc/routers/workspace/create.ts index f042c5f4ab..88636f5998 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/create.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/create.ts @@ -81,6 +81,7 @@ export const createWorkspace = t.procedure updatedAtM: null, deletedAtM: null, partitionId: null, + k8sNamespace: null, }; await tx.insert(schema.workspaces).values(workspace); diff --git a/go/apps/ctrl/workflows/project/create.go b/go/apps/ctrl/workflows/project/create.go index 423e4ed684..ddb3377055 100644 --- a/go/apps/ctrl/workflows/project/create.go +++ b/go/apps/ctrl/workflows/project/create.go @@ -39,6 +39,33 @@ func (s *Service) CreateProject(ctx restate.ObjectContext, req *hydrav1.CreatePr return nil, err } + k8sNamespace := workspace.K8sNamespace.String + // This should really be in a dedicated createWorkspace call I think, + // but this works for now + if k8sNamespace == "" { + k8sNamespace, err = restate.Run(ctx, func(runCtx restate.RunContext) (string, error) { + name := uid.Nano(12) + res, err := db.Query.UpdateWorkspaceK8sNamespace(runCtx, s.db.RW(), db.UpdateWorkspaceK8sNamespaceParams{ + ID: workspace.ID, + K8sNamespace: sql.NullString{Valid: true, String: name}, + }) + if err != nil { + return "", err + } + affected, err := res.RowsAffected() + if err != nil { + return "", err + } + if affected != 1 { + return "", errors.New("failed to update workspace k8s namespace") + } + return name, nil + }) + if err != nil { + return nil, err + } + } + projectID, err := restate.Run(ctx, func(runCtx restate.RunContext) (string, error) { return uid.New(uid.ProjectPrefix), nil }, restate.WithName("generate project ID")) @@ -46,8 +73,8 @@ func (s *Service) CreateProject(ctx restate.ObjectContext, req *hydrav1.CreatePr return nil, err } - _, err = restate.Run(ctx, func(runCtx restate.RunContext) (restate.Void, error) { - return restate.Void{}, db.Query.InsertProject(runCtx, s.db.RW(), db.InsertProjectParams{ + err = restate.RunVoid(ctx, func(runCtx restate.RunContext) error { + return db.Query.InsertProject(runCtx, s.db.RW(), db.InsertProjectParams{ ID: projectID, WorkspaceID: workspace.ID, Name: req.Name, @@ -81,8 +108,8 @@ func (s *Service) CreateProject(ctx restate.ObjectContext, req *hydrav1.CreatePr return nil, err } - _, err = restate.Run(ctx, func(runCtx restate.RunContext) (restate.Void, error) { - return restate.Void{}, db.Query.InsertEnvironment(runCtx, s.db.RW(), db.InsertEnvironmentParams{ + err = restate.RunVoid(ctx, func(runCtx restate.RunContext) error { + return db.Query.InsertEnvironment(runCtx, s.db.RW(), db.InsertEnvironmentParams{ ID: environmentID, WorkspaceID: workspace.ID, ProjectID: projectID, @@ -109,10 +136,10 @@ func (s *Service) CreateProject(ctx restate.ObjectContext, req *hydrav1.CreatePr replicas = uint32(3) } - _, err = restate.Run(ctx, func(runCtx restate.RunContext) (restate.Void, error) { + err = restate.RunVoid(ctx, func(runCtx restate.RunContext) error { _, err := s.krane.CreateGateway(runCtx, connect.NewRequest(&kranev1.CreateGatewayRequest{ Gateway: &kranev1.GatewayRequest{ - Namespace: workspace.ID, + Namespace: k8sNamespace, WorkspaceId: workspace.ID, GatewayId: gatewayID, Image: "nginx:latest", // TODO @@ -121,7 +148,7 @@ func (s *Service) CreateProject(ctx restate.ObjectContext, req *hydrav1.CreatePr MemorySizeMib: uint64(256), }, })) - return restate.Void{}, err + return err }, restate.WithName("provision gateway")) if err != nil { diff --git a/go/apps/krane/backend/kubernetes/deployment_create.go b/go/apps/krane/backend/kubernetes/deployment_create.go index 5b1421485a..412817750a 100644 --- a/go/apps/krane/backend/kubernetes/deployment_create.go +++ b/go/apps/krane/backend/kubernetes/deployment_create.go @@ -6,6 +6,7 @@ import ( "strings" "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/apps/krane/backend/kubernetes/labels" kranev1 "github.com/unkeyed/unkey/go/gen/proto/krane/v1" "github.com/unkeyed/unkey/go/pkg/ptr" appsv1 "k8s.io/api/apps/v1" @@ -67,12 +68,14 @@ import ( // Returns DEPLOYMENT_STATUS_PENDING as pods may not be immediately scheduled // and ready for traffic after creation. func (k *k8s) CreateDeployment(ctx context.Context, req *connect.Request[kranev1.CreateDeploymentRequest]) (*connect.Response[kranev1.CreateDeploymentResponse], error) { - k8sDeploymentID := safeIDForK8s(req.Msg.GetDeployment().GetDeploymentId()) - namespace := safeIDForK8s(req.Msg.GetDeployment().GetNamespace()) + + namespace := req.Msg.GetDeployment().GetNamespace() + deploymentID := req.Msg.GetDeployment().GetDeploymentId() + const krane = "krane" k.logger.Info("creating deployment", "namespace", namespace, - "deployment_id", k8sDeploymentID, + "deployment_id", deploymentID, ) service, err := k.clientset.CoreV1(). @@ -88,15 +91,11 @@ func (k *k8s) CreateDeployment(ctx context.Context, req *connect.Request[kranev1 //nolint:exhaustruct &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ - Name: k8sDeploymentID, - Namespace: namespace, + GenerateName: "svc-", + Namespace: namespace, Labels: map[string]string{ - "unkey.deployment.id": k8sDeploymentID, - "unkey.managed.by": "krane", - }, - - Annotations: map[string]string{ - "unkey.deployment.id": k8sDeploymentID, + labels.DeploymentID: deploymentID, + labels.ManagedBy: krane, }, }, @@ -104,7 +103,8 @@ func (k *k8s) CreateDeployment(ctx context.Context, req *connect.Request[kranev1 Spec: corev1.ServiceSpec{ Type: corev1.ServiceTypeClusterIP, // Use ClusterIP for internal communication Selector: map[string]string{ - "unkey.deployment.id": k8sDeploymentID, + labels.DeploymentID: deploymentID, + labels.ManagedBy: krane, }, ClusterIP: "None", PublishNotReadyAddresses: true, @@ -130,11 +130,11 @@ func (k *k8s) CreateDeployment(ctx context.Context, req *connect.Request[kranev1 //nolint: exhaustruct &appsv1.StatefulSet{ ObjectMeta: metav1.ObjectMeta{ - Name: k8sDeploymentID, - Namespace: namespace, + GenerateName: "dpl-", + Namespace: namespace, Labels: map[string]string{ - "unkey.deployment.id": k8sDeploymentID, - "unkey.managed.by": "krane", + labels.DeploymentID: deploymentID, + labels.ManagedBy: krane, }, }, @@ -144,14 +144,14 @@ func (k *k8s) CreateDeployment(ctx context.Context, req *connect.Request[kranev1 Replicas: ptr.P(int32(req.Msg.GetDeployment().GetReplicas())), //nolint: gosec Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{ - "unkey.deployment.id": k8sDeploymentID, + labels.DeploymentID: deploymentID, }, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{ - "unkey.deployment.id": k8sDeploymentID, - "unkey.managed.by": "krane", + labels.DeploymentID: deploymentID, + labels.ManagedBy: krane, }, Annotations: map[string]string{}, }, @@ -171,7 +171,7 @@ func (k *k8s) CreateDeployment(ctx context.Context, req *connect.Request[kranev1 RestartPolicy: corev1.RestartPolicyAlways, Containers: []corev1.Container{ { - Name: "todo", + Image: req.Msg.GetDeployment().GetImage(), Ports: []corev1.ContainerPort{ { @@ -219,7 +219,7 @@ func (k *k8s) CreateDeployment(ctx context.Context, req *connect.Request[kranev1 { APIVersion: "apps/v1", Kind: "StatefulSet", - Name: k8sDeploymentID, + Name: sfs.Name, UID: sfs.UID, }, } diff --git a/go/apps/krane/backend/kubernetes/deployment_delete.go b/go/apps/krane/backend/kubernetes/deployment_delete.go index f90c9f482f..a36ad77ab4 100644 --- a/go/apps/krane/backend/kubernetes/deployment_delete.go +++ b/go/apps/krane/backend/kubernetes/deployment_delete.go @@ -3,7 +3,6 @@ package kubernetes import ( "context" "fmt" - "strings" "connectrpc.com/connect" kranev1 "github.com/unkeyed/unkey/go/gen/proto/krane/v1" @@ -15,32 +14,72 @@ import ( // DeleteDeployment removes a deployment and all associated Kubernetes resources. // // This method performs a complete cleanup of a deployment by removing both -// the Service and StatefulSet resources. The cleanup follows Kubernetes -// best practices for resource deletion with background propagation to -// ensure associated pods and other resources are properly terminated. +// the Service and StatefulSet resources. Resources are selected by their +// deployment-id label rather than by name, following Kubernetes best practices +// for resource management. func (k *k8s) DeleteDeployment(ctx context.Context, req *connect.Request[kranev1.DeleteDeploymentRequest]) (*connect.Response[kranev1.DeleteDeploymentResponse], error) { - k8sDeploymentID := strings.ReplaceAll(req.Msg.GetDeploymentId(), "_", "-") - namespace := safeIDForK8s(req.Msg.GetNamespace()) + deploymentID := req.Msg.GetDeploymentId() + namespace := req.Msg.GetNamespace() k.logger.Info("deleting deployment", "namespace", namespace, - "deployment_id", k8sDeploymentID, + "deployment_id", deploymentID, ) + // Create label selector for this deployment + labelSelector := fmt.Sprintf("deployment-id=%s", deploymentID) + //nolint: exhaustruct - err := k.clientset.CoreV1().Services(namespace).Delete(ctx, k8sDeploymentID, metav1.DeleteOptions{ + deleteOptions := metav1.DeleteOptions{ PropagationPolicy: ptr.P(metav1.DeletePropagationBackground), + } + + // List and delete Services with this deployment-id label + //nolint: exhaustruct + serviceList, err := k.clientset.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, }) - if err != nil && !apierrors.IsNotFound(err) { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to delete service: %w", err)) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list services: %w", err)) } + for _, service := range serviceList.Items { + k.logger.Debug("deleting service", + "name", service.Name, + "deployment_id", deploymentID, + ) + err = k.clientset.CoreV1().Services(namespace).Delete(ctx, service.Name, deleteOptions) + if err != nil && !apierrors.IsNotFound(err) { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to delete service %s: %w", service.Name, err)) + } + } + + // List and delete StatefulSets with this deployment-id label //nolint: exhaustruct - err = k.clientset.AppsV1().StatefulSets(namespace).Delete(ctx, k8sDeploymentID, metav1.DeleteOptions{ - PropagationPolicy: ptr.P(metav1.DeletePropagationBackground), + statefulSetList, err := k.clientset.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, }) - if err != nil && !apierrors.IsNotFound(err) { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to delete deployment: %w", err)) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list statefulsets: %w", err)) } + + for _, statefulSet := range statefulSetList.Items { + k.logger.Debug("deleting statefulset", + "name", statefulSet.Name, + "deployment_id", deploymentID, + ) + err = k.clientset.AppsV1().StatefulSets(namespace).Delete(ctx, statefulSet.Name, deleteOptions) + if err != nil && !apierrors.IsNotFound(err) { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to delete statefulset %s: %w", statefulSet.Name, err)) + } + } + + k.logger.Info("deployment deleted successfully", + "namespace", namespace, + "deployment_id", deploymentID, + "services_deleted", len(serviceList.Items), + "statefulsets_deleted", len(statefulSetList.Items), + ) + return connect.NewResponse(&kranev1.DeleteDeploymentResponse{}), nil } diff --git a/go/apps/krane/backend/kubernetes/deployment_get.go b/go/apps/krane/backend/kubernetes/deployment_get.go index 53456303de..26b252afa1 100644 --- a/go/apps/krane/backend/kubernetes/deployment_get.go +++ b/go/apps/krane/backend/kubernetes/deployment_get.go @@ -5,10 +5,10 @@ import ( "fmt" "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/apps/krane/backend/kubernetes/labels" kranev1 "github.com/unkeyed/unkey/go/gen/proto/krane/v1" "github.com/unkeyed/unkey/go/pkg/assert" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -19,35 +19,38 @@ import ( // It returns detailed information about each pod instance including stable // DNS addresses, current status, and resource allocation. func (k *k8s) GetDeployment(ctx context.Context, req *connect.Request[kranev1.GetDeploymentRequest]) (*connect.Response[kranev1.GetDeploymentResponse], error) { + deploymentID := req.Msg.GetDeploymentId() + namespace := req.Msg.GetNamespace() + err := assert.All( - assert.NotEmpty(req.Msg.GetNamespace()), - assert.NotEmpty(req.Msg.GetDeploymentId()), + assert.NotEmpty(namespace), + assert.NotEmpty(deploymentID), ) if err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } - k8sDeploymentID := safeIDForK8s(req.Msg.GetDeploymentId()) - namespace := safeIDForK8s(req.Msg.GetNamespace()) + k.logger.Info("getting deployment", "deployment_id", deploymentID) - k.logger.Info("getting deployment", "deployment_id", k8sDeploymentID) + // Create label selector for this deployment + labelSelector := fmt.Sprintf("%s=%s,%s=%s", labels.DeploymentID, deploymentID, labels.ManagedBy, krane) - // Get the Job by name (deployment_id) + // List StatefulSets with this deployment-id label // nolint: exhaustruct - sfs, err := k.clientset.AppsV1().StatefulSets(namespace).Get(ctx, k8sDeploymentID, metav1.GetOptions{}) + statefulSets, err := k.clientset.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) if err != nil { - if errors.IsNotFound(err) { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("deployment not found: %s", k8sDeploymentID)) - } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get deployment: %w", err)) + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list statefulsets: %w", err)) } - // Check if this job is managed by Krane - managedBy, exists := sfs.Labels["unkey.managed.by"] - if !exists || managedBy != "krane" { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("deployment not found: %s", k8sDeploymentID)) + if len(statefulSets.Items) == 0 { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("deployment not found: %s", deploymentID)) } + // Use the first (and should be only) StatefulSet + sfs := &statefulSets.Items[0] + // Determine job status var status kranev1.DeploymentStatus if sfs.Status.AvailableReplicas == sfs.Status.Replicas { @@ -56,26 +59,35 @@ func (k *k8s) GetDeployment(ctx context.Context, req *connect.Request[kranev1.Ge status = kranev1.DeploymentStatus_DEPLOYMENT_STATUS_PENDING } - // Get the service to retrieve port info + // List Services with this deployment-id label // nolint: exhaustruct - service, err := k.clientset.CoreV1().Services(namespace).Get(ctx, k8sDeploymentID, metav1.GetOptions{}) + services, err := k.clientset.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("could not load service: %s", k8sDeploymentID)) + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list services: %w", err)) + } + + if len(services.Items) == 0 { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("no service found for deployment: %s", deploymentID)) } + + // Use the first service + service := &services.Items[0] var port int32 = 8080 // default if len(service.Spec.Ports) > 0 { port = service.Spec.Ports[0].Port } // Get all pods belonging to this stateful set - labelSelector := metav1.FormatLabelSelector(&metav1.LabelSelector{ + podLabelSelector := metav1.FormatLabelSelector(&metav1.LabelSelector{ MatchExpressions: nil, MatchLabels: sfs.Spec.Selector.MatchLabels, }) //nolint: exhaustruct pods, err := k.clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: labelSelector, + LabelSelector: podLabelSelector, }) if err != nil { return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list pods: %w", err)) @@ -113,7 +125,7 @@ func (k *k8s) GetDeployment(ctx context.Context, req *connect.Request[kranev1.Ge } k.logger.Info("deployment found", - "deployment_id", k8sDeploymentID, + "deployment_id", deploymentID, "status", status.String(), "port", port, "pod_count", len(instances), diff --git a/go/apps/krane/backend/kubernetes/gateway_create.go b/go/apps/krane/backend/kubernetes/gateway_create.go index 689ec43c09..198155681f 100644 --- a/go/apps/krane/backend/kubernetes/gateway_create.go +++ b/go/apps/krane/backend/kubernetes/gateway_create.go @@ -5,6 +5,7 @@ import ( "fmt" "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/apps/krane/backend/kubernetes/labels" kranev1 "github.com/unkeyed/unkey/go/gen/proto/krane/v1" "github.com/unkeyed/unkey/go/pkg/ptr" appsv1 "k8s.io/api/apps/v1" @@ -16,11 +17,12 @@ import ( ) func (k *k8s) CreateGateway(ctx context.Context, req *connect.Request[kranev1.CreateGatewayRequest]) (*connect.Response[kranev1.CreateGatewayResponse], error) { - k8sGatewayID := safeIDForK8s(req.Msg.GetGateway().GetGatewayId()) - namespace := safeIDForK8s(req.Msg.GetGateway().GetNamespace()) - k.logger.Info("creating deployment", + gatewayID := req.Msg.GetGateway().GetGatewayId() + namespace := req.Msg.GetGateway().GetNamespace() + + k.logger.Info("creating gateway", "namespace", namespace, - "deployment_id", k8sGatewayID, + "gateway_id", gatewayID, ) // Ensure namespace exists @@ -34,7 +36,7 @@ func (k *k8s) CreateGateway(ctx context.Context, req *connect.Request[kranev1.Cr ObjectMeta: metav1.ObjectMeta{ Name: namespace, Labels: map[string]string{ - "unkey.managed.by": "krane", + labels.ManagedBy: krane, }, }, }, metav1.CreateOptions{}) @@ -53,11 +55,11 @@ func (k *k8s) CreateGateway(ctx context.Context, req *connect.Request[kranev1.Cr //nolint: exhaustruct &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ - Name: k8sGatewayID, - Namespace: namespace, + GenerateName: "gw-", + Namespace: namespace, Labels: map[string]string{ - "unkey.gateway.id": k8sGatewayID, - "unkey.managed.by": "krane", + labels.GatewayID: gatewayID, + labels.ManagedBy: krane, }, }, @@ -66,14 +68,14 @@ func (k *k8s) CreateGateway(ctx context.Context, req *connect.Request[kranev1.Cr Replicas: ptr.P(int32(req.Msg.GetGateway().GetReplicas())), //nolint: gosec Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{ - "unkey.gateway.id": k8sGatewayID, + labels.GatewayID: gatewayID, }, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{ - "unkey.gateway.id": k8sGatewayID, - "unkey.managed.by": "krane", + labels.GatewayID: gatewayID, + labels.ManagedBy: krane, }, Annotations: map[string]string{}, }, @@ -81,7 +83,7 @@ func (k *k8s) CreateGateway(ctx context.Context, req *connect.Request[kranev1.Cr RestartPolicy: corev1.RestartPolicyAlways, Containers: []corev1.Container{ { - Name: k8sGatewayID, + Name: "gateway", Image: req.Msg.GetGateway().GetImage(), Ports: []corev1.ContainerPort{ { @@ -122,14 +124,11 @@ func (k *k8s) CreateGateway(ctx context.Context, req *connect.Request[kranev1.Cr //nolint:exhaustruct &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ - Name: k8sGatewayID, - Namespace: namespace, + GenerateName: "gw-svc-", + Namespace: namespace, Labels: map[string]string{ - "unkey.gateway.id": k8sGatewayID, - "unkey.managed.by": "krane", - }, - Annotations: map[string]string{ - "unkey.gateway.id": k8sGatewayID, + labels.GatewayID: gatewayID, + labels.ManagedBy: krane, }, OwnerReferences: []metav1.OwnerReference{ // Automatically clean up the service when the Deployment gets deleted @@ -137,7 +136,7 @@ func (k *k8s) CreateGateway(ctx context.Context, req *connect.Request[kranev1.Cr { APIVersion: "apps/v1", Kind: "Deployment", - Name: k8sGatewayID, + Name: deployment.Name, UID: deployment.UID, }, }, @@ -146,7 +145,7 @@ func (k *k8s) CreateGateway(ctx context.Context, req *connect.Request[kranev1.Cr Spec: corev1.ServiceSpec{ Type: corev1.ServiceTypeClusterIP, // Use ClusterIP for internal communication Selector: map[string]string{ - "unkey.gateway.id": k8sGatewayID, + labels.GatewayID: gatewayID, }, //nolint:exhaustruct Ports: []corev1.ServicePort{ diff --git a/go/apps/krane/backend/kubernetes/gateway_delete.go b/go/apps/krane/backend/kubernetes/gateway_delete.go index 95907d09d3..4138fa0083 100644 --- a/go/apps/krane/backend/kubernetes/gateway_delete.go +++ b/go/apps/krane/backend/kubernetes/gateway_delete.go @@ -3,30 +3,84 @@ package kubernetes import ( "context" "fmt" - "strings" "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/apps/krane/backend/kubernetes/labels" kranev1 "github.com/unkeyed/unkey/go/gen/proto/krane/v1" "github.com/unkeyed/unkey/go/pkg/ptr" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// DeleteGateway removes a gateway and all associated Kubernetes resources. +// +// This method performs a complete cleanup of a gateway by removing both +// the Service and Deployment resources. Resources are selected by their +// gateway-id label rather than by name, following Kubernetes best practices +// for resource management. func (k *k8s) DeleteGateway(ctx context.Context, req *connect.Request[kranev1.DeleteGatewayRequest]) (*connect.Response[kranev1.DeleteGatewayResponse], error) { - k8sGatewayID := strings.ReplaceAll(req.Msg.GetGatewayId(), "_", "-") + gatewayID := req.Msg.GetGatewayId() + namespace := req.Msg.GetNamespace() - namespace := safeIDForK8s(req.Msg.GetGatewayId()) - - k.logger.Info("deleting deployment", + k.logger.Info("deleting gateway", "namespace", namespace, - "gateway_id", k8sGatewayID, + "gateway_id", gatewayID, ) - err := k.clientset.AppsV1().Deployments(req.Msg.GetNamespace()).Delete(ctx, k8sGatewayID, metav1.DeleteOptions{ + // Create label selector for this gateway + labelSelector := fmt.Sprintf("%s=%s,%s=%s", labels.GatewayID, gatewayID, labels.ManagedBy, krane) + + //nolint: exhaustruct + deleteOptions := metav1.DeleteOptions{ PropagationPolicy: ptr.P(metav1.DeletePropagationBackground), + } + + // List and delete Services with this gateway-id label + //nolint: exhaustruct + serviceList, err := k.clientset.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, }) - if err != nil && !apierrors.IsNotFound(err) { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to delete deployment: %w", err)) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list services: %w", err)) } + + for _, service := range serviceList.Items { + k.logger.Debug("deleting service", + "name", service.Name, + "gateway_id", gatewayID, + ) + err = k.clientset.CoreV1().Services(namespace).Delete(ctx, service.Name, deleteOptions) + if err != nil && !apierrors.IsNotFound(err) { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to delete service %s: %w", service.Name, err)) + } + } + + // List and delete Deployments with this gateway-id label + //nolint: exhaustruct + deploymentList, err := k.clientset.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list deployments: %w", err)) + } + + for _, deployment := range deploymentList.Items { + k.logger.Debug("deleting deployment", + "name", deployment.Name, + "gateway_id", gatewayID, + ) + err = k.clientset.AppsV1().Deployments(namespace).Delete(ctx, deployment.Name, deleteOptions) + if err != nil && !apierrors.IsNotFound(err) { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to delete deployment %s: %w", deployment.Name, err)) + } + } + + k.logger.Info("gateway deleted successfully", + "namespace", namespace, + "gateway_id", gatewayID, + "services_deleted", len(serviceList.Items), + "deployments_deleted", len(deploymentList.Items), + ) + return connect.NewResponse(&kranev1.DeleteGatewayResponse{}), nil } diff --git a/go/apps/krane/backend/kubernetes/gateway_get.go b/go/apps/krane/backend/kubernetes/gateway_get.go index d73b6e0719..8956e87af3 100644 --- a/go/apps/krane/backend/kubernetes/gateway_get.go +++ b/go/apps/krane/backend/kubernetes/gateway_get.go @@ -5,10 +5,10 @@ import ( "fmt" "connectrpc.com/connect" + "github.com/unkeyed/unkey/go/apps/krane/backend/kubernetes/labels" kranev1 "github.com/unkeyed/unkey/go/gen/proto/krane/v1" "github.com/unkeyed/unkey/go/pkg/assert" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -27,25 +27,34 @@ func (k *k8s) GetGateway(ctx context.Context, req *connect.Request[kranev1.GetGa return nil, connect.NewError(connect.CodeInvalidArgument, err) } - k8sgatewayID := safeIDForK8s(req.Msg.GetGatewayId()) - namespace := safeIDForK8s(req.Msg.GetNamespace()) + gatewayID := req.Msg.GetGatewayId() + namespace := req.Msg.GetNamespace() - k.logger.Info("getting gateway", "gateway_id", k8sgatewayID) + k.logger.Info("getting gateway", "gateway_id", gatewayID) - // Get the deployment by name (gateway_id) + // Create label selector for this gateway + labelSelector := fmt.Sprintf("%s=%s", labels.GatewayID, gatewayID) + + // List Deployments with this gateway-id label // nolint: exhaustruct - deployment, err := k.clientset.AppsV1().Deployments(namespace).Get(ctx, k8sgatewayID, metav1.GetOptions{}) + deployments, err := k.clientset.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) if err != nil { - if errors.IsNotFound(err) { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("deployment not found: %s", k8sgatewayID)) - } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to get deployment: %w", err)) + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list deployments: %w", err)) } + if len(deployments.Items) == 0 { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("gateway not found: %s", gatewayID)) + } + + // Use the first (and should be only) Deployment + deployment := &deployments.Items[0] + // Check if this gateway is managed by Krane - managedBy, exists := deployment.Labels["unkey.managed.by"] + managedBy, exists := deployment.Labels[labels.ManagedBy] if !exists || managedBy != "krane" { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("deployment not found: %s", k8sgatewayID)) + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("gateway not found: %s", gatewayID)) } // Determine gateway status @@ -56,12 +65,22 @@ func (k *k8s) GetGateway(ctx context.Context, req *connect.Request[kranev1.GetGa status = kranev1.GatewayStatus_GATEWAY_STATUS_PENDING } - // Get the service to retrieve port info - service, err := k.clientset.CoreV1().Services(namespace).Get(ctx, k8sgatewayID, metav1.GetOptions{}) + // List Services with this gateway-id label + // nolint: exhaustruct + services, err := k.clientset.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("could not load service: %s", k8sgatewayID)) + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list services: %w", err)) } - var port int32 = 8080 // default + + if len(services.Items) == 0 { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("no service found for gateway: %s", gatewayID)) + } + + // Use the first service + service := &services.Items[0] + var port int32 = 8040 // default gateway port if len(service.Spec.Ports) > 0 { port = service.Spec.Ports[0].Port } @@ -102,8 +121,8 @@ func (k *k8s) GetGateway(ctx context.Context, req *connect.Request[kranev1.GetGa }) } - k.logger.Info("deployment found", - "deployment_id", k8sgatewayID, + k.logger.Info("gateway found", + "gateway_id", gatewayID, "status", status.String(), "port", port, "pod_count", len(instances), diff --git a/go/apps/krane/backend/kubernetes/id.go b/go/apps/krane/backend/kubernetes/id.go deleted file mode 100644 index a3445acea1..0000000000 --- a/go/apps/krane/backend/kubernetes/id.go +++ /dev/null @@ -1,11 +0,0 @@ -package kubernetes - -import "strings" - -// safeIDForK8s converts deployment IDs to Kubernetes-safe resource names. -// -// Replaces underscores with hyphens and converts to lowercase to comply -// with DNS-1123 label requirements. -func safeIDForK8s(id string) string { - return strings.ToLower(strings.ReplaceAll(id, "_", "-")) -} diff --git a/go/apps/krane/backend/kubernetes/krane.go b/go/apps/krane/backend/kubernetes/krane.go new file mode 100644 index 0000000000..3f5e840b95 --- /dev/null +++ b/go/apps/krane/backend/kubernetes/krane.go @@ -0,0 +1,3 @@ +package kubernetes + +const krane = "krane" diff --git a/go/apps/krane/backend/kubernetes/labels/labels.go b/go/apps/krane/backend/kubernetes/labels/labels.go new file mode 100644 index 0000000000..5f0022563a --- /dev/null +++ b/go/apps/krane/backend/kubernetes/labels/labels.go @@ -0,0 +1,9 @@ +package labels + +// Labels +const ( + DeploymentID = "unkey.com/deployment.id" + GatewayID = "unkey.com/gateway.id" + GatewayVersion = "unkey.com/gateway.version" + ManagedBy = "app.kubernetes.io/managed-by" +) diff --git a/go/go.mod b/go/go.mod index da64de8938..31af633d3a 100644 --- a/go/go.mod +++ b/go/go.mod @@ -17,7 +17,7 @@ require ( github.com/bytedance/sonic v1.14.2 github.com/depot/depot-go v0.5.1 github.com/docker/cli v28.4.0+incompatible - github.com/docker/docker v28.4.0+incompatible + github.com/docker/docker v28.5.1+incompatible github.com/docker/go-connections v0.6.0 github.com/getkin/kin-openapi v0.133.0 github.com/go-acme/lego/v4 v4.25.2 @@ -32,7 +32,7 @@ require ( github.com/pb33f/libopenapi-validator v0.6.4 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.14.0 - github.com/restatedev/sdk-go v0.21.0 + github.com/restatedev/sdk-go v0.22.0 github.com/segmentio/kafka-go v0.4.49 github.com/shirou/gopsutil/v4 v4.25.8 github.com/spiffe/go-spiffe/v2 v2.6.0 @@ -51,8 +51,8 @@ require ( go.opentelemetry.io/otel/sdk/log v0.14.0 go.opentelemetry.io/otel/sdk/metric v1.38.0 go.opentelemetry.io/otel/trace v1.38.0 - golang.org/x/net v0.44.0 - golang.org/x/text v0.29.0 + golang.org/x/net v0.45.0 + golang.org/x/text v0.30.0 google.golang.org/protobuf v1.36.10 k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.1 @@ -188,7 +188,7 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/tetratelabs/wazero v1.10.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -212,18 +212,18 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/log v0.14.0 // indirect - go.opentelemetry.io/proto/otlp v1.8.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.42.0 // indirect + golang.org/x/crypto v0.43.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/mod v0.28.0 // indirect golang.org/x/oauth2 v0.31.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.35.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect golang.org/x/time v0.13.0 // indirect golang.org/x/tools v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect diff --git a/go/go.sum b/go/go.sum index 0a390767d5..fb7eb5a95e 100644 --- a/go/go.sum +++ b/go/go.sum @@ -138,6 +138,7 @@ github.com/docker/cli v28.4.0+incompatible h1:RBcf3Kjw2pMtwui5V0DIMdyeab8glEw5QY github.com/docker/cli v28.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.4.0+incompatible h1:KVC7bz5zJY/4AZe/78BIvCnPsLaC9T/zh72xnlrTTOk= github.com/docker/docker v28.4.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -372,6 +373,8 @@ github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01j github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/restatedev/sdk-go v0.21.0 h1:A0Ss0o8ZvUReGmiGJYe9dB8lIXWu/tytsKDt/UIGXAA= github.com/restatedev/sdk-go v0.21.0/go.mod h1:T3G/P3VBSRTvdverfEiCVVcsNSymzO5ebIyUU6uRqk8= +github.com/restatedev/sdk-go v0.22.0 h1:Jr7+4hUvZoYwrc/35ZXR7Ykvj3vhc6F2I5lQpOC7xm0= +github.com/restatedev/sdk-go v0.22.0/go.mod h1:2G757yGe0Ihwcb+Z/HZUscQ0g3PFTyueO0f8qlqxWDo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= @@ -420,6 +423,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8= +github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -520,6 +525,8 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -538,6 +545,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -552,6 +560,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -572,15 +582,18 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/go/pkg/db/key_find_live_by_hash.sql_generated.go b/go/pkg/db/key_find_live_by_hash.sql_generated.go index 06e1b616d0..85ce69ce62 100644 --- a/go/pkg/db/key_find_live_by_hash.sql_generated.go +++ b/go/pkg/db/key_find_live_by_hash.sql_generated.go @@ -15,7 +15,7 @@ SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id, a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection, ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at, - ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, + ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.k8s_namespace, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, i.id as identity_table_id, i.external_id as identity_external_id, i.meta as identity_meta, @@ -146,7 +146,7 @@ type FindLiveKeyByHashRow struct { // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id, // a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection, // ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at, -// ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, +// ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.k8s_namespace, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, // i.id as identity_table_id, // i.external_id as identity_external_id, // i.meta as identity_meta, @@ -283,6 +283,7 @@ func (q *Queries) FindLiveKeyByHash(ctx context.Context, db DBTX, hash string) ( &i.Workspace.Name, &i.Workspace.Slug, &i.Workspace.PartitionID, + &i.Workspace.K8sNamespace, &i.Workspace.Plan, &i.Workspace.Tier, &i.Workspace.StripeCustomerID, diff --git a/go/pkg/db/key_find_live_by_id.sql_generated.go b/go/pkg/db/key_find_live_by_id.sql_generated.go index b410859ee0..8443773aad 100644 --- a/go/pkg/db/key_find_live_by_id.sql_generated.go +++ b/go/pkg/db/key_find_live_by_id.sql_generated.go @@ -15,7 +15,7 @@ SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id, a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection, ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at, - ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, + ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.k8s_namespace, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, i.id as identity_table_id, i.external_id as identity_external_id, i.meta as identity_meta, @@ -147,7 +147,7 @@ type FindLiveKeyByIDRow struct { // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id, // a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection, // ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at, -// ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, +// ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.k8s_namespace, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, // i.id as identity_table_id, // i.external_id as identity_external_id, // i.meta as identity_meta, @@ -285,6 +285,7 @@ func (q *Queries) FindLiveKeyByID(ctx context.Context, db DBTX, id string) (Find &i.Workspace.Name, &i.Workspace.Slug, &i.Workspace.PartitionID, + &i.Workspace.K8sNamespace, &i.Workspace.Plan, &i.Workspace.Tier, &i.Workspace.StripeCustomerID, diff --git a/go/pkg/db/models_generated.go b/go/pkg/db/models_generated.go index 231a63474b..3319ab8411 100644 --- a/go/pkg/db/models_generated.go +++ b/go/pkg/db/models_generated.go @@ -1015,6 +1015,7 @@ type Workspace struct { Name string `db:"name"` Slug string `db:"slug"` PartitionID sql.NullString `db:"partition_id"` + K8sNamespace sql.NullString `db:"k8s_namespace"` Plan NullWorkspacesPlan `db:"plan"` Tier sql.NullString `db:"tier"` StripeCustomerID sql.NullString `db:"stripe_customer_id"` diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index 05ed0ac708..9e2cc182dd 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -506,7 +506,7 @@ type Querier interface { // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id, // a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection, // ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at, - // ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, + // ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.k8s_namespace, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, // i.id as identity_table_id, // i.external_id as identity_external_id, // i.meta as identity_meta, @@ -597,7 +597,7 @@ type Querier interface { // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, k.pending_migration_id, // a.id, a.name, a.workspace_id, a.ip_whitelist, a.auth_type, a.key_auth_id, a.created_at_m, a.updated_at_m, a.deleted_at_m, a.delete_protection, // ka.id, ka.workspace_id, ka.created_at_m, ka.updated_at_m, ka.deleted_at_m, ka.store_encrypted_keys, ka.default_prefix, ka.default_bytes, ka.size_approx, ka.size_last_updated_at, - // ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, + // ws.id, ws.org_id, ws.name, ws.slug, ws.partition_id, ws.k8s_namespace, ws.plan, ws.tier, ws.stripe_customer_id, ws.stripe_subscription_id, ws.beta_features, ws.features, ws.subscriptions, ws.enabled, ws.delete_protection, ws.created_at_m, ws.updated_at_m, ws.deleted_at_m, // i.id as identity_table_id, // i.external_id as identity_external_id, // i.meta as identity_meta, @@ -911,7 +911,7 @@ type Querier interface { FindRolesByNames(ctx context.Context, db DBTX, arg FindRolesByNamesParams) ([]FindRolesByNamesRow, error) //FindWorkspaceByID // - // SELECT id, org_id, name, slug, partition_id, plan, tier, stripe_customer_id, stripe_subscription_id, beta_features, features, subscriptions, enabled, delete_protection, created_at_m, updated_at_m, deleted_at_m FROM `workspaces` + // SELECT id, org_id, name, slug, partition_id, k8s_namespace, plan, tier, stripe_customer_id, stripe_subscription_id, beta_features, features, subscriptions, enabled, delete_protection, created_at_m, updated_at_m, deleted_at_m FROM `workspaces` // WHERE id = ? FindWorkspaceByID(ctx context.Context, db DBTX, id string) (Workspace, error) //GetKeyAuthByID @@ -1824,7 +1824,7 @@ type Querier interface { //ListWorkspaces // // SELECT - // w.id, w.org_id, w.name, w.slug, w.partition_id, w.plan, w.tier, w.stripe_customer_id, w.stripe_subscription_id, w.beta_features, w.features, w.subscriptions, w.enabled, w.delete_protection, w.created_at_m, w.updated_at_m, w.deleted_at_m, + // w.id, w.org_id, w.name, w.slug, w.partition_id, w.k8s_namespace, w.plan, w.tier, w.stripe_customer_id, w.stripe_subscription_id, w.beta_features, w.features, w.subscriptions, w.enabled, w.delete_protection, w.created_at_m, w.updated_at_m, w.deleted_at_m, // q.workspace_id, q.requests_per_month, q.logs_retention_days, q.audit_logs_retention_days, q.team // FROM `workspaces` w // LEFT JOIN quota q ON w.id = q.workspace_id @@ -2087,6 +2087,12 @@ type Querier interface { // SET enabled = ? // WHERE id = ? UpdateWorkspaceEnabled(ctx context.Context, db DBTX, arg UpdateWorkspaceEnabledParams) (sql.Result, error) + //UpdateWorkspaceK8sNamespace + // + // UPDATE `workspaces` + // SET k8s_namespace = ? + // WHERE id = ? and k8s_namespace is null + UpdateWorkspaceK8sNamespace(ctx context.Context, db DBTX, arg UpdateWorkspaceK8sNamespaceParams) (sql.Result, error) //UpdateWorkspacePlan // // UPDATE `workspaces` diff --git a/go/pkg/db/queries/workspace_update_k8s_namespace.sql b/go/pkg/db/queries/workspace_update_k8s_namespace.sql new file mode 100644 index 0000000000..73f8cb2610 --- /dev/null +++ b/go/pkg/db/queries/workspace_update_k8s_namespace.sql @@ -0,0 +1,5 @@ +-- name: UpdateWorkspaceK8sNamespace :execresult +UPDATE `workspaces` +SET k8s_namespace = sqlc.arg(k8s_namespace) +WHERE id = sqlc.arg(id) and k8s_namespace is null +; diff --git a/go/pkg/db/schema.sql b/go/pkg/db/schema.sql index d856d164bd..fc609821c5 100644 --- a/go/pkg/db/schema.sql +++ b/go/pkg/db/schema.sql @@ -192,6 +192,7 @@ CREATE TABLE `workspaces` ( `name` varchar(256) NOT NULL, `slug` varchar(64) NOT NULL, `partition_id` varchar(256), + `k8s_namespace` varchar(63), `plan` enum('free','pro','enterprise') DEFAULT 'free', `tier` varchar(256) DEFAULT 'Free', `stripe_customer_id` varchar(256), @@ -206,7 +207,8 @@ CREATE TABLE `workspaces` ( `deleted_at_m` bigint, CONSTRAINT `workspaces_id` PRIMARY KEY(`id`), CONSTRAINT `workspaces_org_id_unique` UNIQUE(`org_id`), - CONSTRAINT `workspaces_slug_unique` UNIQUE(`slug`) + CONSTRAINT `workspaces_slug_unique` UNIQUE(`slug`), + CONSTRAINT `workspaces_k8s_namespace_unique` UNIQUE(`k8s_namespace`) ); CREATE TABLE `key_migration_errors` ( diff --git a/go/pkg/db/workspace_find_by_id.sql_generated.go b/go/pkg/db/workspace_find_by_id.sql_generated.go index ed1b040ea3..dca029f813 100644 --- a/go/pkg/db/workspace_find_by_id.sql_generated.go +++ b/go/pkg/db/workspace_find_by_id.sql_generated.go @@ -10,13 +10,13 @@ import ( ) const findWorkspaceByID = `-- name: FindWorkspaceByID :one -SELECT id, org_id, name, slug, partition_id, plan, tier, stripe_customer_id, stripe_subscription_id, beta_features, features, subscriptions, enabled, delete_protection, created_at_m, updated_at_m, deleted_at_m FROM ` + "`" + `workspaces` + "`" + ` +SELECT id, org_id, name, slug, partition_id, k8s_namespace, plan, tier, stripe_customer_id, stripe_subscription_id, beta_features, features, subscriptions, enabled, delete_protection, created_at_m, updated_at_m, deleted_at_m FROM ` + "`" + `workspaces` + "`" + ` WHERE id = ? ` // FindWorkspaceByID // -// SELECT id, org_id, name, slug, partition_id, plan, tier, stripe_customer_id, stripe_subscription_id, beta_features, features, subscriptions, enabled, delete_protection, created_at_m, updated_at_m, deleted_at_m FROM `workspaces` +// SELECT id, org_id, name, slug, partition_id, k8s_namespace, plan, tier, stripe_customer_id, stripe_subscription_id, beta_features, features, subscriptions, enabled, delete_protection, created_at_m, updated_at_m, deleted_at_m FROM `workspaces` // WHERE id = ? func (q *Queries) FindWorkspaceByID(ctx context.Context, db DBTX, id string) (Workspace, error) { row := db.QueryRowContext(ctx, findWorkspaceByID, id) @@ -27,6 +27,7 @@ func (q *Queries) FindWorkspaceByID(ctx context.Context, db DBTX, id string) (Wo &i.Name, &i.Slug, &i.PartitionID, + &i.K8sNamespace, &i.Plan, &i.Tier, &i.StripeCustomerID, diff --git a/go/pkg/db/workspace_list.sql_generated.go b/go/pkg/db/workspace_list.sql_generated.go index 4abc2d11ab..fcbd58468d 100644 --- a/go/pkg/db/workspace_list.sql_generated.go +++ b/go/pkg/db/workspace_list.sql_generated.go @@ -11,7 +11,7 @@ import ( const listWorkspaces = `-- name: ListWorkspaces :many SELECT - w.id, w.org_id, w.name, w.slug, w.partition_id, w.plan, w.tier, w.stripe_customer_id, w.stripe_subscription_id, w.beta_features, w.features, w.subscriptions, w.enabled, w.delete_protection, w.created_at_m, w.updated_at_m, w.deleted_at_m, + w.id, w.org_id, w.name, w.slug, w.partition_id, w.k8s_namespace, w.plan, w.tier, w.stripe_customer_id, w.stripe_subscription_id, w.beta_features, w.features, w.subscriptions, w.enabled, w.delete_protection, w.created_at_m, w.updated_at_m, w.deleted_at_m, q.workspace_id, q.requests_per_month, q.logs_retention_days, q.audit_logs_retention_days, q.team FROM ` + "`" + `workspaces` + "`" + ` w LEFT JOIN quota q ON w.id = q.workspace_id @@ -28,7 +28,7 @@ type ListWorkspacesRow struct { // ListWorkspaces // // SELECT -// w.id, w.org_id, w.name, w.slug, w.partition_id, w.plan, w.tier, w.stripe_customer_id, w.stripe_subscription_id, w.beta_features, w.features, w.subscriptions, w.enabled, w.delete_protection, w.created_at_m, w.updated_at_m, w.deleted_at_m, +// w.id, w.org_id, w.name, w.slug, w.partition_id, w.k8s_namespace, w.plan, w.tier, w.stripe_customer_id, w.stripe_subscription_id, w.beta_features, w.features, w.subscriptions, w.enabled, w.delete_protection, w.created_at_m, w.updated_at_m, w.deleted_at_m, // q.workspace_id, q.requests_per_month, q.logs_retention_days, q.audit_logs_retention_days, q.team // FROM `workspaces` w // LEFT JOIN quota q ON w.id = q.workspace_id @@ -50,6 +50,7 @@ func (q *Queries) ListWorkspaces(ctx context.Context, db DBTX, cursor string) ([ &i.Workspace.Name, &i.Workspace.Slug, &i.Workspace.PartitionID, + &i.Workspace.K8sNamespace, &i.Workspace.Plan, &i.Workspace.Tier, &i.Workspace.StripeCustomerID, diff --git a/go/pkg/db/workspace_update_k8s_namespace.sql_generated.go b/go/pkg/db/workspace_update_k8s_namespace.sql_generated.go new file mode 100644 index 0000000000..482d7a3d6b --- /dev/null +++ b/go/pkg/db/workspace_update_k8s_namespace.sql_generated.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: workspace_update_k8s_namespace.sql + +package db + +import ( + "context" + "database/sql" +) + +const updateWorkspaceK8sNamespace = `-- name: UpdateWorkspaceK8sNamespace :execresult +UPDATE ` + "`" + `workspaces` + "`" + ` +SET k8s_namespace = ? +WHERE id = ? and k8s_namespace is null +` + +type UpdateWorkspaceK8sNamespaceParams struct { + K8sNamespace sql.NullString `db:"k8s_namespace"` + ID string `db:"id"` +} + +// UpdateWorkspaceK8sNamespace +// +// UPDATE `workspaces` +// SET k8s_namespace = ? +// WHERE id = ? and k8s_namespace is null +func (q *Queries) UpdateWorkspaceK8sNamespace(ctx context.Context, db DBTX, arg UpdateWorkspaceK8sNamespaceParams) (sql.Result, error) { + return db.ExecContext(ctx, updateWorkspaceK8sNamespace, arg.K8sNamespace, arg.ID) +} diff --git a/go/pkg/uid/nanoid.go b/go/pkg/uid/nanoid.go new file mode 100644 index 0000000000..5b37418f15 --- /dev/null +++ b/go/pkg/uid/nanoid.go @@ -0,0 +1,67 @@ +package uid + +import ( + "math/rand/v2" + "strings" +) + +// nanoAlphabet defines the character set used for generating nano IDs. +// Uses lowercase letters and digits for URL-safe, case-insensitive identifiers. +const nanoAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + +// Nano generates a simple random alphanumeric string. +// +// Unlike the main UID generation functions in this package, Nano creates +// shorter, non-timestamped identifiers suitable for cases where you need +// simple, random strings without chronological ordering guarantees or prefixes. +// +// The generated string consists only of random alphanumeric characters +// (lowercase letters and digits). The default length is 8 characters, +// but this can be overridden by providing a custom length parameter. +// +// SECURITY WARNING: This function uses math/rand/v2 and is NOT cryptographically +// secure. The random values are predictable and MUST NOT be used for: +// - API keys or access tokens +// - Session identifiers +// - Password reset tokens +// - Any externally-exposed identifiers +// - Any security-sensitive use cases +// +// Nano is intended ONLY for internal identifiers, test fixtures, and other +// non-sensitive uses where predictability is acceptable. +// +// Note: math/rand/v2 automatically seeds the global random source with a +// cryptographically secure random value, so manual seeding is not required. +// +// Example usage: +// +// // Generate with default 8 characters +// id := uid.Nano() // e.g., "k3n5p8x2" +// +// // Generate with custom 12 characters +// id := uid.Nano(12) // e.g., "a9k2n5p8x3m7" +// +// // Use with a prefix manually +// id := "usr_" + uid.Nano() // e.g., "usr_k3n5p8x2" +// +// For production use cases requiring cryptographic security, use [New] which +// provides cryptographically secure random generation via crypto/rand. +func Nano(length ...int) string { + // Default to 8 characters if no length specified + n := 8 + if len(length) > 0 { + n = length[0] + } + + // Pre-allocate builder for efficiency + // We use strings.Builder to avoid repeated string concatenations + // which would create O(n) intermediate strings + var b strings.Builder + b.Grow(n) + + for i := 0; i < n; i++ { + b.WriteByte(nanoAlphabet[rand.IntN(len(nanoAlphabet))]) + } + + return b.String() +} diff --git a/go/pkg/uid/nanoid_test.go b/go/pkg/uid/nanoid_test.go new file mode 100644 index 0000000000..4c9fd0a158 --- /dev/null +++ b/go/pkg/uid/nanoid_test.go @@ -0,0 +1,91 @@ +package uid + +import ( + "strings" + "testing" +) + +// TestNano verifies that Nano generates strings with correct format and length. +func TestNano(t *testing.T) { + tests := []struct { + name string + length []int + wantLen int + }{ + { + name: "default length", + length: nil, + wantLen: 8, // 8 default chars + }, + { + name: "custom length 12", + length: []int{12}, + wantLen: 12, + }, + { + name: "custom length 5", + length: []int{5}, + wantLen: 5, + }, + { + name: "zero length", + length: []int{0}, + wantLen: 0, + }, + { + name: "large length", + length: []int{32}, + wantLen: 32, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := Nano(tt.length...) + + // Check total length + if len(id) != tt.wantLen { + t.Errorf("Nano() length = %v, want %v", len(id), tt.wantLen) + } + + // Check that all characters are valid + for _, char := range id { + if !strings.ContainsRune(nanoAlphabet, char) { + t.Errorf("Nano() contains invalid character: %c", char) + } + } + }) + } +} + +// TestNanoUniqueness verifies that consecutive calls produce different strings. +// This is a basic smoke test - not a guarantee of randomness quality. +func TestNanoUniqueness(t *testing.T) { + const iterations = 100 + seen := make(map[string]bool) + + for range iterations { + id := Nano() // Using default length + if seen[id] { + t.Errorf("Duplicate ID generated: %v", id) + } + seen[id] = true + } +} + +// TestNanoWithPrefix demonstrates how to use Nano with manual prefixing. +func TestNanoWithPrefix(t *testing.T) { + // Example of using Nano with a manual prefix + prefix := "usr_" + id := prefix + Nano() + + if !strings.HasPrefix(id, prefix) { + t.Errorf("Expected ID to start with prefix %v, got %v", prefix, id) + } + + // Total length should be prefix + 8 default chars + expectedLen := len(prefix) + 8 + if len(id) != expectedLen { + t.Errorf("Expected total length %v, got %v", expectedLen, len(id)) + } +} diff --git a/internal/db/src/schema/workspaces.ts b/internal/db/src/schema/workspaces.ts index fd078b4fd5..21ba64b182 100644 --- a/internal/db/src/schema/workspaces.ts +++ b/internal/db/src/schema/workspaces.ts @@ -28,6 +28,8 @@ export const workspaces = mysqlTable("workspaces", { // Deployment platform - which partition this workspace deploys to partitionId: varchar("partition_id", { length: 256 }), + k8sNamespace: varchar("k8s_namespace", { length: 63 }).unique(), + // different plans, this should only be used for visualisations in the ui // @deprecated - use tier plan: mysqlEnum("plan", ["free", "pro", "enterprise"]).default("free"),