From 8d0122fa9c82e96749ebd7926982c182e7a55e10 Mon Sep 17 00:00:00 2001 From: chronark Date: Wed, 19 Nov 2025 19:00:29 +0100 Subject: [PATCH 1/2] feat: define protos for kranes gateway management --- go/proto/krane/v1/gateway.proto | 72 +++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 go/proto/krane/v1/gateway.proto diff --git a/go/proto/krane/v1/gateway.proto b/go/proto/krane/v1/gateway.proto new file mode 100644 index 0000000000..8609d6cf47 --- /dev/null +++ b/go/proto/krane/v1/gateway.proto @@ -0,0 +1,72 @@ +syntax = "proto3"; + +package krane.v1; + +option go_package = "github.com/unkeyed/unkey/go/gen/proto/krane/v1;kranev1"; + +service GatewayService { + // CreateGateway + rpc CreateGateway(CreateGatewayRequest) returns (CreateGatewayResponse); + + // GetGateway + rpc GetGateway(GetGatewayRequest) returns (GetGatewayResponse); + + // DeleteGateway + rpc DeleteGateway(DeleteGatewayRequest) returns (DeleteGatewayResponse); +} + +message GatewayRequest { + string namespace = 1; + string workspace_id = 2; + string gateway_id = 3; + + string image = 4; + + uint32 replicas = 5; + uint32 cpu_millicores = 6; + uint64 memory_size_mib = 7; +} + +message CreateGatewayRequest { + GatewayRequest gateway = 1; +} + +message CreateGatewayResponse { + GatewayStatus status = 1; +} + +message UpdateGatewayRequest { + GatewayRequest gateway = 1; +} +message UpdateGatewayResponse { + repeated string pod_ids = 1; +} + +message DeleteGatewayRequest { + string namespace = 1; + string gateway_id = 2; +} + +message DeleteGatewayResponse {} + +message GetGatewayRequest { + string namespace = 1; + string gateway_id = 2; +} + +message GetGatewayResponse { + repeated GatewayInstance instances = 2; +} + +enum GatewayStatus { + GATEWAY_STATUS_UNSPECIFIED = 0; + GATEWAY_STATUS_PENDING = 1; // Gateway request accepted, container/pod creation in progress + GATEWAY_STATUS_RUNNING = 2; // Container/pod is running and healthy + GATEWAY_STATUS_TERMINATING = 3; // Container/pod is being terminated +} + +message GatewayInstance { + string id = 1; + string address = 2; + GatewayStatus status = 3; +} From 7bb2d5553605fcd0b209aaf56a89466a70ba062d Mon Sep 17 00:00:00 2001 From: chronark Date: Wed, 19 Nov 2025 19:01:00 +0100 Subject: [PATCH 2/2] feat: implement krane gateway RPCs for docker --- ...ate_deployment.go => deployment_create.go} | 0 ...ete_deployment.go => deployment_delete.go} | 0 .../{get_deployment.go => deployment_get.go} | 0 .../krane/backend/docker/gateway_create.go | 106 ++++++++++++++++++ .../krane/backend/docker/gateway_delete.go | 48 ++++++++ go/apps/krane/backend/docker/gateway_get.go | 65 +++++++++++ go/apps/krane/backend/docker/service.go | 2 + 7 files changed, 221 insertions(+) rename go/apps/krane/backend/docker/{create_deployment.go => deployment_create.go} (100%) rename go/apps/krane/backend/docker/{delete_deployment.go => deployment_delete.go} (100%) rename go/apps/krane/backend/docker/{get_deployment.go => deployment_get.go} (100%) create mode 100644 go/apps/krane/backend/docker/gateway_create.go create mode 100644 go/apps/krane/backend/docker/gateway_delete.go create mode 100644 go/apps/krane/backend/docker/gateway_get.go diff --git a/go/apps/krane/backend/docker/create_deployment.go b/go/apps/krane/backend/docker/deployment_create.go similarity index 100% rename from go/apps/krane/backend/docker/create_deployment.go rename to go/apps/krane/backend/docker/deployment_create.go diff --git a/go/apps/krane/backend/docker/delete_deployment.go b/go/apps/krane/backend/docker/deployment_delete.go similarity index 100% rename from go/apps/krane/backend/docker/delete_deployment.go rename to go/apps/krane/backend/docker/deployment_delete.go diff --git a/go/apps/krane/backend/docker/get_deployment.go b/go/apps/krane/backend/docker/deployment_get.go similarity index 100% rename from go/apps/krane/backend/docker/get_deployment.go rename to go/apps/krane/backend/docker/deployment_get.go diff --git a/go/apps/krane/backend/docker/gateway_create.go b/go/apps/krane/backend/docker/gateway_create.go new file mode 100644 index 0000000000..698e96ffae --- /dev/null +++ b/go/apps/krane/backend/docker/gateway_create.go @@ -0,0 +1,106 @@ +package docker + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/go-connections/nat" + kranev1 "github.com/unkeyed/unkey/go/gen/proto/krane/v1" +) + +// CreateGateway creates containers for a gateway with the specified replica count. +// +// Creates multiple containers with shared labels, dynamic port mapping to port 8040, +// and resource limits. Returns GATEWAY_STATUS_PENDING as containers may not be +// immediately ready. +func (d *docker) CreateGateway(ctx context.Context, req *connect.Request[kranev1.CreateGatewayRequest]) (*connect.Response[kranev1.CreateGatewayResponse], error) { + gateway := req.Msg.GetGateway() + d.logger.Info("creating gateway", + "gateway_id", gateway.GetGatewayId(), + "image", gateway.GetImage(), + ) + + // Ensure image exists locally (pull if not present) + if err := d.ensureImageExists(ctx, gateway.GetImage()); err != nil { + return nil, connect.NewError(connect.CodeInternal, + fmt.Errorf("failed to ensure image exists: %w", err)) + } + + // Configure port mapping + exposedPorts := nat.PortSet{ + "8040/tcp": struct{}{}, + } + + portBindings := nat.PortMap{ + "8040/tcp": []nat.PortBinding{ + { + HostIP: "0.0.0.0", + HostPort: "0", // Docker will assign a random available port + }, + }, + } + + // Configure resource limits + cpuNanos := int64(gateway.GetCpuMillicores()) * 1_000_000 // Convert millicores to nanoseconds + memoryBytes := int64(gateway.GetMemorySizeMib()) * 1024 * 1024 //nolint:gosec // Intentional conversion + + //nolint:exhaustruct // Docker SDK types have many optional fields + containerConfig := &container.Config{ + Image: gateway.GetImage(), + Labels: map[string]string{ + "unkey.gateway.id": gateway.GetGatewayId(), + "unkey.managed.by": "krane", + }, + ExposedPorts: exposedPorts, + Env: []string{ + fmt.Sprintf("UNKEY_WORKSPACE_ID=%s", gateway.GetWorkspaceId()), + fmt.Sprintf("UNKEY_GATEWAY_ID=%s", gateway.GetGatewayId()), + fmt.Sprintf("UNKEY_IMAGE=%s", gateway.GetImage()), + }, + } + + //nolint:exhaustruct // Docker SDK types have many optional fields + hostConfig := &container.HostConfig{ + PortBindings: portBindings, + RestartPolicy: container.RestartPolicy{ + Name: "unless-stopped", + }, + Resources: container.Resources{ + NanoCPUs: cpuNanos, + Memory: memoryBytes, + }, + } + + //nolint:exhaustruct // Docker SDK types have many optional fields + networkConfig := &network.NetworkingConfig{} + + // Create container + + for i := range req.Msg.GetGateway().GetReplicas() { + //nolint:exhaustruct // Docker SDK types have many optional fields + resp, err := d.client.ContainerCreate( + ctx, + containerConfig, + hostConfig, + networkConfig, + nil, + fmt.Sprintf("%s-%d", gateway.GetGatewayId(), i), + ) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to create container: %w", err)) + } + + //nolint:exhaustruct // Docker SDK types have many optional fields + err = d.client.ContainerStart(ctx, resp.ID, container.StartOptions{}) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to start container: %w", err)) + } + } + + return connect.NewResponse(&kranev1.CreateGatewayResponse{ + Status: kranev1.GatewayStatus_GATEWAY_STATUS_PENDING, + }), nil +} diff --git a/go/apps/krane/backend/docker/gateway_delete.go b/go/apps/krane/backend/docker/gateway_delete.go new file mode 100644 index 0000000000..744bf56208 --- /dev/null +++ b/go/apps/krane/backend/docker/gateway_delete.go @@ -0,0 +1,48 @@ +package docker + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + kranev1 "github.com/unkeyed/unkey/go/gen/proto/krane/v1" +) + +// DeleteGateway removes all containers for a gateway. +// +// Finds containers by gateway ID label and forcibly removes them with +// volumes and network links to ensure complete cleanup. +func (d *docker) DeleteGateway(ctx context.Context, req *connect.Request[kranev1.DeleteGatewayRequest]) (*connect.Response[kranev1.DeleteGatewayResponse], error) { + gatewayID := req.Msg.GetGatewayId() + + d.logger.Info("getting gateway", "gateway_id", gatewayID) + + containers, err := d.client.ContainerList(ctx, container.ListOptions{ + Size: false, + Latest: false, + Since: "", + Before: "", + Limit: 0, + All: true, + Filters: filters.NewArgs( + filters.Arg("label", fmt.Sprintf("unkey.gateway.id=%s", gatewayID)), + ), + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list containers: %w", err)) + } + + for _, c := range containers { + err := d.client.ContainerRemove(ctx, c.ID, container.RemoveOptions{ + RemoveVolumes: true, + RemoveLinks: true, + Force: true, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to remove container: %w", err)) + } + } + return connect.NewResponse(&kranev1.DeleteGatewayResponse{}), nil +} diff --git a/go/apps/krane/backend/docker/gateway_get.go b/go/apps/krane/backend/docker/gateway_get.go new file mode 100644 index 0000000000..7484a58946 --- /dev/null +++ b/go/apps/krane/backend/docker/gateway_get.go @@ -0,0 +1,65 @@ +package docker + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + kranev1 "github.com/unkeyed/unkey/go/gen/proto/krane/v1" +) + +// GetGateway retrieves container status and addresses for a deployment. +// +// Finds containers by gateway ID label and returns instance information +// with host.docker.internal addresses using dynamically assigned ports. +func (d *docker) GetGateway(ctx context.Context, req *connect.Request[kranev1.GetGatewayRequest]) (*connect.Response[kranev1.GetGatewayResponse], error) { + gatewayID := req.Msg.GetGatewayId() + d.logger.Info("getting gateway", "gateway_id", gatewayID) + + //nolint:exhaustruct // Docker SDK types have many optional fields + containers, err := d.client.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filters.NewArgs( + filters.Arg("label", fmt.Sprintf("unkey.gateway.id=%s", gatewayID)), + ), + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list containers: %w", err)) + } + + res := &kranev1.GetGatewayResponse{ + Instances: []*kranev1.GatewayInstance{}, + } + + for _, c := range containers { + d.logger.Info("container found", "container", c) + + // Determine container status + status := kranev1.GatewayStatus_GATEWAY_STATUS_UNSPECIFIED + switch c.State { + case container.StateRunning: + status = kranev1.GatewayStatus_GATEWAY_STATUS_RUNNING + case container.StateExited: + status = kranev1.GatewayStatus_GATEWAY_STATUS_TERMINATING + case container.StateCreated: + status = kranev1.GatewayStatus_GATEWAY_STATUS_PENDING + } + + d.logger.Info("gateway found", + "gateway_id", gatewayID, + "container_id", c.ID, + "status", status.String(), + "port", c.Ports[0].PublicPort, + ) + + res.Instances = append(res.Instances, &kranev1.GatewayInstance{ + Id: c.ID, + Address: fmt.Sprintf("host.docker.internal:%d", c.Ports[0].PublicPort), + Status: status, + }) + } + + return connect.NewResponse(res), nil +} diff --git a/go/apps/krane/backend/docker/service.go b/go/apps/krane/backend/docker/service.go index ecd9dcdd3c..c50bf2f591 100644 --- a/go/apps/krane/backend/docker/service.go +++ b/go/apps/krane/backend/docker/service.go @@ -25,9 +25,11 @@ type docker struct { registryAuth string // base64 encoded auth for pulls kranev1connect.UnimplementedDeploymentServiceHandler + kranev1connect.UnimplementedGatewayServiceHandler } var _ kranev1connect.DeploymentServiceHandler = (*docker)(nil) +var _ kranev1connect.GatewayServiceHandler = (*docker)(nil) // Config holds configuration for the Docker backend type Config struct {