Skip to content

Commit 1a80e8d

Browse files
authored
support byoc for cloud agents (#875)
1 parent 36135a7 commit 1a80e8d

File tree

3 files changed

+180
-0
lines changed

3 files changed

+180
-0
lines changed

pkg/cloudagents/client.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ func (c *Client) DeployAgent(
123123
return c.uploadAndBuild(ctx, agentID, resp.PresignedUrl, resp.PresignedPostRequest, source, excludeFiles, buildLogStreamWriter)
124124
}
125125

126+
// RegisterAgent creates an agent record without uploading source or triggering a build.
127+
// Use this when you intend to push a prebuilt image immediately after via GetPushTarget.
128+
func (c *Client) RegisterAgent(ctx context.Context, secrets []*lkproto.AgentSecret, regions []string) (string, error) {
129+
resp, err := c.AgentClient.CreateAgent(ctx, &lkproto.CreateAgentRequest{
130+
Secrets: secrets,
131+
Regions: regions,
132+
})
133+
if err != nil {
134+
return "", err
135+
}
136+
return resp.AgentId, nil
137+
}
138+
126139
// CreatePrivateLink creates a new private link for cloud agents.
127140
func (c *Client) CreatePrivateLink(ctx context.Context, req *lkproto.CreatePrivateLinkRequest) (*lkproto.CreatePrivateLinkResponse, error) {
128141
return c.AgentClient.CreatePrivateLink(ctx, req)

pkg/cloudagents/push.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2025 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cloudagents
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"io"
22+
"net/http"
23+
"net/url"
24+
)
25+
26+
// PushTarget describes where and how the CLI should push a prebuilt image.
27+
type PushTarget struct {
28+
// ProxyHost is the OCI registry host exposed by cloud-agents (e.g. "agents.livekit.io").
29+
ProxyHost string `json:"proxy_host"`
30+
// Name is the OCI repository name to use in /v2/{name}/... paths.
31+
Name string `json:"name"`
32+
// Tag is the version tag cloud-agents generated; use this as the image tag.
33+
Tag string `json:"tag"`
34+
}
35+
36+
// GetPushTarget asks cloud-agents for the OCI proxy location for the given agent.
37+
// The caller should then push the image to ProxyHost/Name:Tag using a transport
38+
// returned by NewRegistryTransport.
39+
func (c *Client) GetPushTarget(ctx context.Context, agentID string) (*PushTarget, error) {
40+
params := url.Values{}
41+
params.Add("agent_id", agentID)
42+
fullURL := fmt.Sprintf("%s/push-target?%s", c.agentsURL, params.Encode())
43+
44+
req, err := c.newRequestWithContext(ctx, http.MethodGet, fullURL, nil)
45+
if err != nil {
46+
return nil, err
47+
}
48+
resp, err := c.httpClient.Do(req)
49+
if err != nil {
50+
return nil, fmt.Errorf("failed to call push-target: %w", err)
51+
}
52+
defer resp.Body.Close()
53+
if resp.StatusCode != http.StatusOK {
54+
body, _ := io.ReadAll(resp.Body)
55+
return nil, fmt.Errorf("push-target returned %d: %s", resp.StatusCode, body)
56+
}
57+
var target PushTarget
58+
if err := json.NewDecoder(resp.Body).Decode(&target); err != nil {
59+
return nil, fmt.Errorf("failed to decode push-target response: %w", err)
60+
}
61+
return &target, nil
62+
}
63+
64+
// NewRegistryTransport returns an http.RoundTripper that injects the LiveKit JWT on every
65+
// request. Pass this to crane via crane.WithTransport when pushing to the cloud-agents
66+
// OCI proxy so the proxy's auth middleware accepts the requests.
67+
func (c *Client) NewRegistryTransport() http.RoundTripper {
68+
return &lkRegistryTransport{base: http.DefaultTransport, client: c}
69+
}
70+
71+
// lkRegistryTransport injects LK auth headers on every HTTP request, allowing crane
72+
// to push through the cloud-agents OCI proxy without doing OCI token negotiation.
73+
type lkRegistryTransport struct {
74+
base http.RoundTripper
75+
client *Client
76+
}
77+
78+
func (t *lkRegistryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
79+
req = req.Clone(req.Context())
80+
if err := t.client.setAuthToken(req); err != nil {
81+
return nil, err
82+
}
83+
t.client.setLivekitHeaders(req)
84+
return t.base.RoundTrip(req)
85+
}

pkg/cloudagents/push_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package cloudagents
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/livekit/protocol/logger"
12+
)
13+
14+
func TestGetPushTargetSuccess(t *testing.T) {
15+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
fmt.Fprintf(w, `{"proxy_host":"agents.livekit.io","name":"livekit","tag":"v1"}`)
17+
}))
18+
defer server.Close()
19+
20+
client := &Client{
21+
httpClient: server.Client(),
22+
logger: logger.GetLogger(),
23+
apiKey: "test-api-key",
24+
apiSecret: "test-api-secret",
25+
agentsURL: server.URL,
26+
}
27+
28+
target, err := client.GetPushTarget(context.Background(), "test-agent")
29+
if err != nil {
30+
t.Fatalf("unexpected error: %v", err)
31+
}
32+
if target.ProxyHost != "agents.livekit.io" || target.Name != "livekit" || target.Tag != "v1" {
33+
t.Fatalf("unexpected push target: %+v", target)
34+
}
35+
}
36+
37+
func TestGetPushTargetNonOK(t *testing.T) {
38+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+
w.WriteHeader(http.StatusBadRequest)
40+
fmt.Fprintf(w, "oops")
41+
}))
42+
defer server.Close()
43+
44+
client := &Client{
45+
httpClient: server.Client(),
46+
logger: logger.GetLogger(),
47+
apiKey: "test-api-key",
48+
apiSecret: "test-api-secret",
49+
agentsURL: server.URL,
50+
}
51+
52+
_, err := client.GetPushTarget(context.Background(), "test-agent")
53+
if err == nil {
54+
t.Fatal("expected error when push-target returns non-OK status")
55+
}
56+
if !strings.Contains(err.Error(), "push-target returned 400") {
57+
t.Fatalf("unexpected error message: %v", err)
58+
}
59+
}
60+
61+
func TestGetPushTargetDecodeError(t *testing.T) {
62+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63+
fmt.Fprintf(w, "invalid-json")
64+
}))
65+
defer server.Close()
66+
67+
client := &Client{
68+
httpClient: server.Client(),
69+
logger: logger.GetLogger(),
70+
apiKey: "test-api-key",
71+
apiSecret: "test-api-secret",
72+
agentsURL: server.URL,
73+
}
74+
75+
_, err := client.GetPushTarget(context.Background(), "test-agent")
76+
if err == nil {
77+
t.Fatal("expected decode error")
78+
}
79+
if !strings.Contains(err.Error(), "failed to decode push-target response") {
80+
t.Fatalf("unexpected error message: %v", err)
81+
}
82+
}

0 commit comments

Comments
 (0)