diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index 550fb0bd..1cdf4eb5 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -345,6 +345,7 @@ var ( DisableSliceFlagSeparator: true, ArgsUsage: "[working-dir]", }, + privateLinkCommands, }, }, } diff --git a/cmd/lk/agent_private_link.go b/cmd/lk/agent_private_link.go new file mode 100644 index 00000000..f709fd46 --- /dev/null +++ b/cmd/lk/agent_private_link.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "fmt" + "strconv" + + "github.com/livekit/livekit-cli/v2/pkg/util" + lkproto "github.com/livekit/protocol/livekit" + "github.com/twitchtv/twirp" + "github.com/urfave/cli/v3" +) + +var privateLinkCommands = &cli.Command{ + Name: "private-link", + Usage: "Manage private links for agents", + Commands: []*cli.Command{ + { + Name: "create", + Usage: "Create a private link", + Description: "Creates a private link to a customer endpoint.\n\n" + + "Currently expects an AWS VPC Endpoint Service Name for --endpoint.\n" + + "Example: com.amazonaws.vpce.us-east-1.vpce-svc-123123a1c43abc123", + Before: createAgentClient, + Action: createPrivateLink, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "Private link name", + Required: true, + }, + &cli.StringFlag{ + Name: "region", + Usage: "LiveKit region", + Required: true, + }, + &cli.UintFlag{ + Name: "port", + Usage: "Destination port", + Required: true, + }, + &cli.StringFlag{ + Name: "endpoint", + Usage: "Customer-provided endpoint identifier", + Required: true, + }, + jsonFlag, + }, + }, + { + Name: "list", + Usage: "List private links with health", + Before: createAgentClient, + Action: listPrivateLinks, + Flags: []cli.Flag{ + jsonFlag, + }, + }, + { + Name: "delete", + Usage: "Delete a private link", + Before: createAgentClient, + Action: deletePrivateLink, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "Private link ID", + Required: true, + }, + jsonFlag, + }, + }, + { + Name: "health-status", + Usage: "Get private link health status", + Before: createAgentClient, + Action: getPrivateLinkHealthStatus, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "Private link ID", + Required: true, + }, + jsonFlag, + }, + }, + }, +} + +func buildCreatePrivateLinkRequest(name, region string, port uint32, awsEndpoint string) *lkproto.CreatePrivateLinkRequest { + return &lkproto.CreatePrivateLinkRequest{ + Name: name, + Region: region, + Port: port, + Config: &lkproto.CreatePrivateLinkRequest_Aws{ + Aws: &lkproto.CreatePrivateLinkRequest_AWSCreateConfig{ + Endpoint: awsEndpoint, + }, + }, + } +} + +func privateLinkServiceDNS(name, projectID string) string { + return fmt.Sprintf("%s-%s.plg.svc", name, projectID) +} + +func buildPrivateLinkListRows(links []*lkproto.PrivateLink, healthByID map[string]*lkproto.PrivateLinkHealthStatus, healthErrByID map[string]error) [][]string { + var rows [][]string + for _, link := range links { + if link == nil { + continue + } + + status := lkproto.PrivateLinkHealthStatus_PRIVATE_LINK_ATTACHMENT_HEALTH_STATUS_UNKNOWN.String() + updatedAt := "-" + + if err, ok := healthErrByID[link.PrivateLinkId]; ok && err != nil { + status = "ERROR" + updatedAt = err.Error() + } else if health, ok := healthByID[link.PrivateLinkId]; ok && health != nil { + status = health.Status.String() + if health.UpdatedAt != nil { + updatedAt = health.UpdatedAt.AsTime().UTC().Format("2006-01-02T15:04:05Z07:00") + } + } + + rows = append(rows, []string{ + link.PrivateLinkId, + link.Name, + link.Region, + strconv.FormatUint(uint64(link.Port), 10), + status, + updatedAt, + }) + } + return rows +} + +func formatPrivateLinkClientError(action string, err error) error { + if twerr, ok := err.(twirp.Error); ok { + return fmt.Errorf("unable to %s private link: %s", action, twerr.Msg()) + } + return fmt.Errorf("unable to %s private link: %w", action, err) +} + +func createPrivateLink(ctx context.Context, cmd *cli.Command) error { + req := buildCreatePrivateLinkRequest(cmd.String("name"), cmd.String("region"), uint32(cmd.Uint("port")), cmd.String("endpoint")) + resp, err := agentsClient.CreatePrivateLink(ctx, req) + if err != nil { + return formatPrivateLinkClientError("create", err) + } + + if cmd.Bool("json") { + util.PrintJSON(resp) + return nil + } + + if resp.PrivateLink == nil { + fmt.Println("Private link created") + return nil + } + + fmt.Printf("Created private link [%s]\n", util.Accented(resp.PrivateLink.PrivateLinkId)) + if project != nil && project.ProjectId != "" { + fmt.Printf("Gateway DNS [%s]\n", util.Accented(privateLinkServiceDNS(req.Name, project.ProjectId))) + } + return nil +} + +func listPrivateLinks(ctx context.Context, cmd *cli.Command) error { + resp, err := agentsClient.ListPrivateLinks(ctx, &lkproto.ListPrivateLinksRequest{}) + if err != nil { + return formatPrivateLinkClientError("list", err) + } + + healthByID := make(map[string]*lkproto.PrivateLinkHealthStatus, len(resp.Items)) + healthErrByID := make(map[string]error) + for _, link := range resp.Items { + if link == nil || link.PrivateLinkId == "" { + continue + } + health, healthErr := agentsClient.GetPrivateLinkHealthStatus(ctx, &lkproto.GetPrivateLinkHealthStatusRequest{ + PrivateLinkId: link.PrivateLinkId, + }) + if healthErr != nil { + healthErrByID[link.PrivateLinkId] = healthErr + continue + } + if health != nil { + healthByID[link.PrivateLinkId] = health.Value + } + } + + if cmd.Bool("json") { + type privateLinkWithHealth struct { + PrivateLink *lkproto.PrivateLink `json:"private_link"` + Health *lkproto.PrivateLinkHealthStatus `json:"health"` + HealthError string `json:"health_error,omitempty"` + } + items := make([]privateLinkWithHealth, 0, len(resp.Items)) + for _, link := range resp.Items { + if link == nil { + continue + } + entry := privateLinkWithHealth{ + PrivateLink: link, + Health: healthByID[link.PrivateLinkId], + } + if err := healthErrByID[link.PrivateLinkId]; err != nil { + entry.HealthError = err.Error() + } + items = append(items, entry) + } + util.PrintJSON(map[string]any{"items": items}) + return nil + } + + if len(resp.Items) == 0 { + fmt.Println("No private links found") + return nil + } + + rows := buildPrivateLinkListRows(resp.Items, healthByID, healthErrByID) + table := util.CreateTable().Headers("ID", "Name", "Region", "Port", "Health", "Updated At").Rows(rows...) + fmt.Println(table) + return nil +} + +func deletePrivateLink(ctx context.Context, cmd *cli.Command) error { + privateLinkID := cmd.String("id") + resp, err := agentsClient.DestroyPrivateLink(ctx, &lkproto.DestroyPrivateLinkRequest{ + PrivateLinkId: privateLinkID, + }) + if err != nil { + return formatPrivateLinkClientError("delete", err) + } + + if cmd.Bool("json") { + util.PrintJSON(resp) + return nil + } + fmt.Printf("Deleted private link [%s]\n", util.Accented(privateLinkID)) + return nil +} + +func getPrivateLinkHealthStatus(ctx context.Context, cmd *cli.Command) error { + privateLinkID := cmd.String("id") + resp, err := agentsClient.GetPrivateLinkHealthStatus(ctx, &lkproto.GetPrivateLinkHealthStatusRequest{ + PrivateLinkId: privateLinkID, + }) + if err != nil { + return formatPrivateLinkClientError("get health status for", err) + } + if cmd.Bool("json") { + util.PrintJSON(resp) + return nil + } + if resp == nil || resp.Value == nil { + return fmt.Errorf("health status unavailable for private link [%s]", privateLinkID) + } + updatedAt := "-" + if resp.Value.UpdatedAt != nil { + updatedAt = resp.Value.UpdatedAt.AsTime().UTC().Format("2006-01-02T15:04:05Z07:00") + } + table := util.CreateTable(). + Headers("ID", "Health", "Updated At"). + Row(privateLinkID, resp.Value.Status.String(), updatedAt) + fmt.Println(table) + return nil +} diff --git a/cmd/lk/agent_private_link_test.go b/cmd/lk/agent_private_link_test.go new file mode 100644 index 00000000..f8cdecf8 --- /dev/null +++ b/cmd/lk/agent_private_link_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + lkproto "github.com/livekit/protocol/livekit" + "github.com/urfave/cli/v3" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func findCommandByName(commands []*cli.Command, name string) *cli.Command { + for _, cmd := range commands { + if cmd != nil && cmd.Name == name { + return cmd + } + } + return nil +} + +func TestAgentPrivateLinkCommandTree(t *testing.T) { + agentCmd := findCommandByName(AgentCommands, "agent") + require.NotNil(t, agentCmd, "top-level 'agent' command must exist") + + privateLinkCmd := findCommandByName(agentCmd.Commands, "private-link") + require.NotNil(t, privateLinkCmd, "'agent private-link' command must exist") + + createCmd := findCommandByName(privateLinkCmd.Commands, "create") + require.NotNil(t, createCmd, "'agent private-link create' command must exist") + require.NotNil(t, createCmd.Action, "'agent private-link create' must have an action") + + listCmd := findCommandByName(privateLinkCmd.Commands, "list") + require.NotNil(t, listCmd, "'agent private-link list' command must exist") + require.NotNil(t, listCmd.Action, "'agent private-link list' must have an action") + + deleteCmd := findCommandByName(privateLinkCmd.Commands, "delete") + require.NotNil(t, deleteCmd, "'agent private-link delete' command must exist") + require.NotNil(t, deleteCmd.Action, "'agent private-link delete' must have an action") + + healthStatusCmd := findCommandByName(privateLinkCmd.Commands, "health-status") + require.NotNil(t, healthStatusCmd, "'agent private-link health-status' command must exist") + require.NotNil(t, healthStatusCmd.Action, "'agent private-link health-status' must have an action") +} + +func TestBuildCreatePrivateLinkRequest_HappyPath(t *testing.T) { + req := buildCreatePrivateLinkRequest("orders-db", "us-east-1", 6379, "com.amazonaws.vpce.us-east-1.vpce-svc-abc123") + require.NotNil(t, req) + + assert.Equal(t, "orders-db", req.Name) + assert.Equal(t, "us-east-1", req.Region) + assert.Equal(t, uint32(6379), req.Port) + + aws := req.GetAws() + require.NotNil(t, aws) + assert.Equal(t, "com.amazonaws.vpce.us-east-1.vpce-svc-abc123", aws.Endpoint) +} + +func TestPrivateLinkServiceDNS(t *testing.T) { + assert.Equal(t, "orders-db-prj_123.plg.svc", privateLinkServiceDNS("orders-db", "prj_123")) +} + +func TestBuildPrivateLinkListRows_EmptyList(t *testing.T) { + rows := buildPrivateLinkListRows([]*lkproto.PrivateLink{}, map[string]*lkproto.PrivateLinkHealthStatus{}, map[string]error{}) + assert.Empty(t, rows) +} + +func TestBuildPrivateLinkListRows_OnePrivateLink(t *testing.T) { + links := []*lkproto.PrivateLink{ + { + PrivateLinkId: "pl-1", + Name: "orders-db", + Region: "us-east-1", + Port: 6379, + }, + } + + now := time.Now().UTC() + healthByID := map[string]*lkproto.PrivateLinkHealthStatus{ + "pl-1": { + Status: lkproto.PrivateLinkHealthStatus_PRIVATE_LINK_ATTACHMENT_HEALTH_STATUS_HEALTHY, + UpdatedAt: timestamppb.New(now), + }, + } + + rows := buildPrivateLinkListRows(links, healthByID, map[string]error{}) + require.Len(t, rows, 1) + assert.Equal(t, "pl-1", rows[0][0]) + assert.Equal(t, "orders-db", rows[0][1]) + assert.Equal(t, "us-east-1", rows[0][2]) + assert.Equal(t, "6379", rows[0][3]) + assert.Equal(t, lkproto.PrivateLinkHealthStatus_PRIVATE_LINK_ATTACHMENT_HEALTH_STATUS_HEALTHY.String(), rows[0][4]) +} + +func TestBuildPrivateLinkListRows_TwoPrivateLinksDifferentRegions(t *testing.T) { + links := []*lkproto.PrivateLink{ + { + PrivateLinkId: "pl-1", + Name: "orders-db", + Region: "us-east-1", + Port: 6379, + }, + { + PrivateLinkId: "pl-2", + Name: "cache", + Region: "eu-west-1", + Port: 6380, + }, + } + + healthByID := map[string]*lkproto.PrivateLinkHealthStatus{ + "pl-1": { + Status: lkproto.PrivateLinkHealthStatus_PRIVATE_LINK_ATTACHMENT_HEALTH_STATUS_HEALTHY, + }, + "pl-2": { + Status: lkproto.PrivateLinkHealthStatus_PRIVATE_LINK_ATTACHMENT_HEALTH_STATUS_UNHEALTHY, + }, + } + + rows := buildPrivateLinkListRows(links, healthByID, map[string]error{}) + require.Len(t, rows, 2) + + assert.Equal(t, "us-east-1", rows[0][2]) + assert.Equal(t, "eu-west-1", rows[1][2]) + assert.Equal(t, lkproto.PrivateLinkHealthStatus_PRIVATE_LINK_ATTACHMENT_HEALTH_STATUS_HEALTHY.String(), rows[0][4]) + assert.Equal(t, lkproto.PrivateLinkHealthStatus_PRIVATE_LINK_ATTACHMENT_HEALTH_STATUS_UNHEALTHY.String(), rows[1][4]) +} + + diff --git a/go.mod b/go.mod index d3f65cdd..bfdfba9c 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,8 @@ require ( github.com/go-logr/logr v1.4.3 github.com/go-task/task/v3 v3.44.1 github.com/joho/godotenv v1.5.1 - github.com/livekit/protocol v1.44.1-0.20260120134243-0914cc74653e - github.com/livekit/server-sdk-go/v2 v2.13.3 + github.com/livekit/protocol v1.44.1-0.20260212060033-b3d04db215ef + github.com/livekit/server-sdk-go/v2 v2.13.4-0.20260212013907-8b9f855080d8 github.com/moby/patternmatcher v0.6.0 github.com/pelletier/go-toml v1.9.5 github.com/pion/rtcp v1.2.16 diff --git a/go.sum b/go.sum index b0068d38..3fc6ebdd 100644 --- a/go.sum +++ b/go.sum @@ -271,12 +271,12 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 h1:dzCBxOGLLWVtQhL7OYK2EGN+5Q+23Mq/jfz4vQisirA= github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22/go.mod h1:mSNtYzSf6iY9xM3UX42VEI+STHvMgHmrYzEHPcdhB8A= -github.com/livekit/protocol v1.44.1-0.20260120134243-0914cc74653e h1:ClpOUpIDcIT/fKh66kOlO4b7fj47ApsITOwCOlvFrGc= -github.com/livekit/protocol v1.44.1-0.20260120134243-0914cc74653e/go.mod h1:BLJHYHErQTu3+fnmfGrzN6CbHxNYiooFIIYGYxXxotw= +github.com/livekit/protocol v1.44.1-0.20260212060033-b3d04db215ef h1:L51mqTPu3W1k0rmBXUuLyniVHntoUWgS9fggFZATGdE= +github.com/livekit/protocol v1.44.1-0.20260212060033-b3d04db215ef/go.mod h1:BLJHYHErQTu3+fnmfGrzN6CbHxNYiooFIIYGYxXxotw= github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw= github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk= -github.com/livekit/server-sdk-go/v2 v2.13.3 h1:RGSQ97/sECeWuvoqqeqj7aaQod4ramb4r0RhgzHG/QA= -github.com/livekit/server-sdk-go/v2 v2.13.3/go.mod h1:+kOsWtRh5dKcrCwFuRTtzJenpJyTfZL7nKnJBHZafC0= +github.com/livekit/server-sdk-go/v2 v2.13.4-0.20260212013907-8b9f855080d8 h1:rLwt0vGkWTMt06ewO31A70HrJyWpKnZZSmCsPfuxMKU= +github.com/livekit/server-sdk-go/v2 v2.13.4-0.20260212013907-8b9f855080d8/go.mod h1:Ah2N5gcbcqLqRfZg/ZMMbQNr0tfd4jC/5CWTC6WSv/I= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=