From 1171ac67559e31d227ce03113604715a6b5264a2 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Thu, 10 Sep 2026 16:12:54 -0400 Subject: [PATCH 1/5] agent: record the agent's name in livekit.toml `lk agent create` and `lk agent config` write [agent] name alongside id. The prebuilt-image create path calls CreateAgent directly since the SDK's RegisterAgent wrapper drops the name from the response. --- cmd/lk/agent.go | 11 +++++++++-- pkg/config/livekit.go | 3 +++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index b84833af..02417237 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -727,14 +727,19 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { buildContext, cancel := context.WithTimeout(ctx, buildTimeout) defer cancel() regions := []string{region} - agentID, err := agentsClient.RegisterAgent(buildContext, secrets, regions) + created, err := agentsClient.AgentClient.CreateAgent(buildContext, &lkproto.CreateAgentRequest{ + Secrets: secrets, + Regions: regions, + }) if err != nil { if twerr, ok := err.(twirp.Error); ok { return fmt.Errorf("unable to create agent: %s", twerr.Msg()) } return fmt.Errorf("unable to create agent: %w", err) } + agentID := created.AgentId lkConfig.Agent.ID = agentID + lkConfig.Agent.Name = created.AgentName if err := lkConfig.SaveTOMLFile(workingDir, tomlFilename); err != nil { return err } @@ -789,6 +794,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { } lkConfig.Agent.ID = resp.AgentId + lkConfig.Agent.Name = resp.AgentName if err := lkConfig.SaveTOMLFile(workingDir, tomlFilename); err != nil { return err } @@ -883,7 +889,8 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { agent := response.Agents[0] lkConfig := config.NewLiveKitTOML(matches[1]) lkConfig.Agent = &config.LiveKitTOMLAgentConfig{ - ID: agent.AgentId, + ID: agent.AgentId, + Name: agent.AgentName, } if err := lkConfig.SaveTOMLFile(workingDir, tomlFilename); err != nil { diff --git a/pkg/config/livekit.go b/pkg/config/livekit.go index 42bc918a..7fcef3c1 100644 --- a/pkg/config/livekit.go +++ b/pkg/config/livekit.go @@ -52,6 +52,9 @@ type LiveKitTOMLProjectConfig struct { type LiveKitTOMLAgentConfig struct { ID string `toml:"id"` + // Identity of the agent under test in simulation runs; self-hosted agents + // set it by hand. + Name string `toml:"name"` } func NewLiveKitTOML(forSubdomain string) *LiveKitTOML { From 6bb6095804edbb9d6a498fb5a57ff292e3899d8d Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 15 Sep 2026 14:35:34 -0400 Subject: [PATCH 2/5] config: move the agent id to a [cloud] block with per-region tables [agent] id becomes [cloud] id, or one [cloud.] id per region for agents deployed to several regions. A legacy [agent] id is still read and migrates to [cloud] on the next save. --- pkg/config/livekit.go | 178 +++++++++++++++++++++++++++++++------ pkg/config/livekit_test.go | 132 +++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 28 deletions(-) create mode 100644 pkg/config/livekit_test.go diff --git a/pkg/config/livekit.go b/pkg/config/livekit.go index 7fcef3c1..148ce0f3 100644 --- a/pkg/config/livekit.go +++ b/pkg/config/livekit.go @@ -44,6 +44,7 @@ type AgentTOML struct { type LiveKitTOML struct { Project *LiveKitTOMLProjectConfig `toml:"project"` // Required Agent *LiveKitTOMLAgentConfig `toml:"agent"` + Cloud *LiveKitTOMLCloudConfig `toml:"-"` } type LiveKitTOMLProjectConfig struct { @@ -51,12 +52,82 @@ type LiveKitTOMLProjectConfig struct { } type LiveKitTOMLAgentConfig struct { - ID string `toml:"id"` + // Deprecated: the id lives in [cloud]; a legacy value is moved there on load. + ID string `toml:"id,omitempty"` // Identity of the agent under test in simulation runs; self-hosted agents // set it by hand. Name string `toml:"name"` } +// LiveKitTOMLCloudConfig holds the Cloud Agents id(s) for the agent. ID is the +// id of a single-region agent; Regions maps region code to id when the agent +// is deployed per region. Exactly one of the two is populated. +type LiveKitTOMLCloudConfig struct { + ID string + Regions map[string]string +} + +// tomlFile is the on-disk shape: [cloud] is "id" and/or one sub-table per +// region, which a struct with fixed fields cannot express. +type tomlFile struct { + Project *LiveKitTOMLProjectConfig `toml:"project"` + Agent *LiveKitTOMLAgentConfig `toml:"agent"` + Cloud map[string]any `toml:"cloud,omitempty"` +} + +func (c *LiveKitTOML) toFile() *tomlFile { + f := &tomlFile{Project: c.Project, Agent: c.Agent} + if c.Cloud == nil { + return f + } + f.Cloud = map[string]any{} + if c.Cloud.ID != "" { + f.Cloud["id"] = c.Cloud.ID + } + for region, id := range c.Cloud.Regions { + f.Cloud[region] = map[string]string{"id": id} + } + return f +} + +func (f *tomlFile) toConfig() (*LiveKitTOML, error) { + c := &LiveKitTOML{Project: f.Project, Agent: f.Agent} + if c.Agent != nil && c.Agent.ID != "" { + c.Cloud = &LiveKitTOMLCloudConfig{ID: c.Agent.ID} + c.Agent.ID = "" + } + if len(f.Cloud) == 0 { + return c, nil + } + if c.Cloud == nil { + c.Cloud = &LiveKitTOMLCloudConfig{} + } + for key, value := range f.Cloud { + switch v := value.(type) { + case string: + if key != "id" { + return nil, fmt.Errorf("[cloud] %s: unknown key: %w", key, ErrInvalidConfig) + } + c.Cloud.ID = v + case map[string]any: + id, _ := v["id"].(string) + if id == "" { + return nil, fmt.Errorf("[cloud.%s] id is required: %w", key, ErrInvalidConfig) + } + if c.Cloud.Regions == nil { + c.Cloud.Regions = map[string]string{} + } + c.Cloud.Regions[key] = id + default: + return nil, fmt.Errorf("[cloud] %s: unexpected value: %w", key, ErrInvalidConfig) + } + } + if c.Cloud.ID != "" && len(c.Cloud.Regions) > 0 { + return nil, fmt.Errorf("[cloud] id and [cloud.] tables are mutually exclusive: %w", ErrInvalidConfig) + } + return c, nil +} + func NewLiveKitTOML(forSubdomain string) *LiveKitTOML { return &LiveKitTOML{ Project: &LiveKitTOMLProjectConfig{ @@ -71,7 +142,62 @@ func (c *LiveKitTOML) WithDefaultAgent() *LiveKitTOML { } func (c *LiveKitTOML) HasAgent() bool { - return c.Agent != nil + return c.Agent != nil || c.Cloud != nil +} + +// AgentIDs returns the Cloud Agents ids keyed by region; a single-region id +// is keyed by "". +func (c *LiveKitTOML) AgentIDs() map[string]string { + if c.Cloud == nil { + return nil + } + if c.Cloud.ID != "" { + return map[string]string{"": c.Cloud.ID} + } + return c.Cloud.Regions +} + +// AgentID returns the id deployed to region, or the only id when region is "". +func (c *LiveKitTOML) AgentID(region string) (string, error) { + ids := c.AgentIDs() + if len(ids) == 0 { + return "", fmt.Errorf("no agent id in [cloud]: %w", ErrInvalidConfig) + } + if region == "" { + if len(ids) > 1 { + return "", fmt.Errorf("%s lists %d regions; pass --region: %w", LiveKitTOMLFile, len(ids), ErrInvalidConfig) + } + for _, id := range ids { + return id, nil + } + } + if id, ok := ids[region]; ok { + return id, nil + } + if id, ok := ids[""]; ok { + return id, nil + } + return "", fmt.Errorf("no agent id for region %q in %s: %w", region, LiveKitTOMLFile, ErrInvalidConfig) +} + +// SetAgentID records id for region. The layout stays flat until a second +// region is added, at which point the existing id is keyed by existingRegion. +func (c *LiveKitTOML) SetAgentID(region, id, existingRegion string) { + if c.Cloud == nil { + c.Cloud = &LiveKitTOMLCloudConfig{} + } + if len(c.Cloud.Regions) == 0 && (c.Cloud.ID == "" || region == "" || region == existingRegion) { + c.Cloud.ID = id + return + } + if c.Cloud.Regions == nil { + c.Cloud.Regions = map[string]string{} + } + if c.Cloud.ID != "" { + c.Cloud.Regions[existingRegion] = c.Cloud.ID + c.Cloud.ID = "" + } + c.Cloud.Regions[region] = id } func (c *LiveKitTOML) SaveTOMLFile(dir string, tomlFileName string) error { @@ -81,7 +207,7 @@ func (c *LiveKitTOML) SaveTOMLFile(dir string, tomlFileName string) error { } defer f.Close() encoder := toml.NewEncoder(f) - if err := encoder.Encode(c); err != nil { + if err := encoder.Encode(c.toFile()); err != nil { return fmt.Errorf("error encoding TOML: %w", err) } util.Statusf("Saving config file [%s]", util.Accented(tomlFileName)) @@ -90,31 +216,27 @@ func (c *LiveKitTOML) SaveTOMLFile(dir string, tomlFileName string) error { func LoadTOMLFile(dir string, tomlFileName string) (*LiveKitTOML, bool, error) { logger.Debugw(fmt.Sprintf("loading %s file", tomlFileName)) - var config *LiveKitTOML = nil - var err error - configExists := false - - tomlFile := filepath.Join(dir, tomlFileName) - - if _, err = os.Stat(tomlFile); err == nil { - configExists = true - - _, err = toml.DecodeFile(tomlFile, &config) - if config.Project == nil { - // Attempt to decode old agent config - var oldConfig AgentTOML - _, err = toml.DecodeFile(tomlFile, &oldConfig) - if err != nil { - return nil, configExists, err - } - config.Project = &LiveKitTOMLProjectConfig{ - Subdomain: oldConfig.ProjectSubdomain, - } - config.Agent = &LiveKitTOMLAgentConfig{} - } - } else { - configExists = !errors.Is(err, fs.ErrNotExist) + path := filepath.Join(dir, tomlFileName) + + if _, err := os.Stat(path); err != nil { + return nil, !errors.Is(err, fs.ErrNotExist), err } - return config, configExists, err + var file tomlFile + if _, err := toml.DecodeFile(path, &file); err != nil { + return nil, true, err + } + if file.Project == nil { + // Attempt to decode old agent config + var oldConfig AgentTOML + if _, err := toml.DecodeFile(path, &oldConfig); err != nil { + return nil, true, err + } + file.Project = &LiveKitTOMLProjectConfig{ + Subdomain: oldConfig.ProjectSubdomain, + } + file.Agent = &LiveKitTOMLAgentConfig{} + } + config, err := file.toConfig() + return config, true, err } diff --git a/pkg/config/livekit_test.go b/pkg/config/livekit_test.go new file mode 100644 index 00000000..4e8e5a77 --- /dev/null +++ b/pkg/config/livekit_test.go @@ -0,0 +1,132 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeTOML(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, LiveKitTOMLFile), []byte(body), 0o644)) + return dir +} + +func TestLoadTOMLFile_LegacyAgentIDMovesToCloud(t *testing.T) { + dir := writeTOML(t, ` +[project] +subdomain = "proj" + +[agent] +id = "CA_legacy" +name = "my-agent" +`) + cfg, exists, err := LoadTOMLFile(dir, LiveKitTOMLFile) + require.True(t, exists) + require.NoError(t, err) + require.Equal(t, "my-agent", cfg.Agent.Name) + require.Empty(t, cfg.Agent.ID) + require.Equal(t, map[string]string{"": "CA_legacy"}, cfg.AgentIDs()) +} + +func TestLoadTOMLFile_CloudRegions(t *testing.T) { + dir := writeTOML(t, ` +[project] +subdomain = "proj" + +[agent] +name = "my-agent" + +[cloud.us-east] +id = "CA_a" + +[cloud.eu-central] +id = "CA_b" +`) + cfg, _, err := LoadTOMLFile(dir, LiveKitTOMLFile) + require.NoError(t, err) + require.Equal(t, map[string]string{"us-east": "CA_a", "eu-central": "CA_b"}, cfg.AgentIDs()) + + id, err := cfg.AgentID("eu-central") + require.NoError(t, err) + require.Equal(t, "CA_b", id) + + _, err = cfg.AgentID("") + require.ErrorIs(t, err, ErrInvalidConfig) + _, err = cfg.AgentID("ap-south") + require.ErrorIs(t, err, ErrInvalidConfig) +} + +func TestLoadTOMLFile_CloudIDAndRegionsAreExclusive(t *testing.T) { + dir := writeTOML(t, ` +[project] +subdomain = "proj" + +[cloud] +id = "CA_a" + +[cloud.us-east] +id = "CA_b" +`) + _, _, err := LoadTOMLFile(dir, LiveKitTOMLFile) + require.ErrorIs(t, err, ErrInvalidConfig) +} + +func TestSaveTOMLFile_RoundTrip(t *testing.T) { + dir := t.TempDir() + cfg := NewLiveKitTOML("proj").WithDefaultAgent() + cfg.Agent.Name = "my-agent" + cfg.SetAgentID("us-east", "CA_a", "") + require.Equal(t, "CA_a", cfg.Cloud.ID, "a single region stays flat") + + cfg.SetAgentID("eu-central", "CA_b", "us-east") + require.Empty(t, cfg.Cloud.ID) + require.Equal(t, map[string]string{"us-east": "CA_a", "eu-central": "CA_b"}, cfg.Cloud.Regions) + + require.NoError(t, cfg.SaveTOMLFile(dir, LiveKitTOMLFile)) + raw, err := os.ReadFile(filepath.Join(dir, LiveKitTOMLFile)) + require.NoError(t, err) + require.Contains(t, string(raw), "[cloud.us-east]") + require.NotContains(t, string(raw), "[agent]\n id") + + loaded, _, err := LoadTOMLFile(dir, LiveKitTOMLFile) + require.NoError(t, err) + require.Equal(t, cfg.Agent.Name, loaded.Agent.Name) + require.Equal(t, cfg.AgentIDs(), loaded.AgentIDs()) +} + +func TestSaveTOMLFile_FlatCloudLayout(t *testing.T) { + dir := t.TempDir() + cfg := NewLiveKitTOML("proj").WithDefaultAgent() + cfg.Agent.Name = "my-agent" + cfg.SetAgentID("", "CA_a", "") + require.NoError(t, cfg.SaveTOMLFile(dir, LiveKitTOMLFile)) + raw, err := os.ReadFile(filepath.Join(dir, LiveKitTOMLFile)) + require.NoError(t, err) + require.Equal(t, `[project] + subdomain = "proj" + +[agent] + name = "my-agent" + +[cloud] + id = "CA_a" +`, string(raw)) +} From 2d437d25053a70bd9fbb821b0d68623e7905c26d Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 15 Sep 2026 14:36:13 -0400 Subject: [PATCH 3/5] agent: split deployAgent into per-agent helpers --- cmd/lk/agent.go | 69 ++++++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index 02417237..1358a5e1 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -922,23 +922,8 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { imageRef := cmd.String("image") imageTar := cmd.String("image-tar") if imageRef != "" || imageTar != "" { - if len(secrets) > 0 { - resp, err := agentsClient.UpdateAgentSecrets(buildContext, &lkproto.UpdateAgentSecretsRequest{ - AgentId: agentId, - Secrets: secrets, - }) - if err != nil { - if twerr, ok := err.(twirp.Error); ok { - return fmt.Errorf("unable to update agent secrets: %s", twerr.Msg()) - } - return fmt.Errorf("unable to update agent secrets: %w", err) - } - if !resp.Success { - return fmt.Errorf("failed to update agent secrets: %s", resp.Message) - } - } - if err := deployPrebuiltImage(buildContext, agentId, imageRef, imageTar, attrs); err != nil { - return fmt.Errorf("unable to deploy prebuilt image: %w", err) + if err := deployPrebuiltImageTo(buildContext, agentId, imageRef, imageTar, secrets, attrs); err != nil { + return err } out.Status("Deployed agent") return nil @@ -969,8 +954,42 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { out.Statusf("Using deployment [%s]", util.Accented(agentDeployment)) } + if err := deploySource(buildContext, agentId, secrets, attrs, agentDeployment); err != nil { + return err + } + reportDeployment(ctx, agentId, agentDeployment) + return nil +} + +// deployPrebuiltImageTo updates the agent's secrets, if any, then pushes the +// prebuilt image to it. +func deployPrebuiltImageTo(ctx context.Context, agentID, imageRef, imageTar string, secrets []*lkproto.AgentSecret, attrs map[string]string) error { + if len(secrets) > 0 { + resp, err := agentsClient.UpdateAgentSecrets(ctx, &lkproto.UpdateAgentSecretsRequest{ + AgentId: agentID, + Secrets: secrets, + }) + if err != nil { + if twerr, ok := err.(twirp.Error); ok { + return fmt.Errorf("unable to update agent secrets: %s", twerr.Msg()) + } + return fmt.Errorf("unable to update agent secrets: %w", err) + } + if !resp.Success { + return fmt.Errorf("failed to update agent secrets: %s", resp.Message) + } + } + if err := deployPrebuiltImage(ctx, agentID, imageRef, imageTar, attrs); err != nil { + return fmt.Errorf("unable to deploy prebuilt image: %w", err) + } + return nil +} + +// deploySource builds workingDir on the server and deploys it to the agent. +// A nil error after Ctrl-C means the deploy continues server-side. +func deploySource(ctx context.Context, agentID string, secrets []*lkproto.AgentSecret, attrs map[string]string, agentDeployment string) error { excludeFiles := []string{fmt.Sprintf("**/%s", config.LiveKitTOMLFile)} - if err := agentsClient.DeployAgentV2(buildContext, agentId, os.DirFS(workingDir), secrets, attrs, agentDeployment, excludeFiles, os.Stderr); err != nil { + if err := agentsClient.DeployAgentV2(ctx, agentID, os.DirFS(workingDir), secrets, attrs, agentDeployment, excludeFiles, os.Stderr); err != nil { if errors.Is(err, context.Canceled) { // The client disconnected (Ctrl-C). Deploys are durable — the build runs to // completion and deploys on the server regardless, so this is not a failure. @@ -982,23 +1001,9 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { } return fmt.Errorf("unable to deploy agent: %w", err) } - - reportDeployment(ctx, agentId, agentDeployment) return nil } -// reportDeployment prints a summary of a completed deployment — the agent name, -// the target deployment, and links to the agent details page and the agent -// console for the deployment. It resolves the name with a single ListAgents -// call; on any failure it falls back to the minimal status line so a successful -// deploy is never reported as a failure. -// -// The version is intentionally omitted: the deploy API doesn't return the new -// version, the agent-level version reflects the production deployment (wrong -// for a non-production deploy), and the per-deployment version isn't populated -// until the agent is scraped. There is no source that is both correct and ready -// synchronously at deploy time, so reporting it would risk showing the wrong -// version. func reportDeployment(ctx context.Context, agentID, deployment string) { targetDeployment := deployment if targetDeployment == "" { From 7be5a9862cd37b3e0a4ecaa4a54ddbd3586ecd4c Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Tue, 15 Sep 2026 14:38:39 -0400 Subject: [PATCH 4/5] agent: write [cloud] id and deploy every region in livekit.toml lk agent create and lk agent config record the new agent under [cloud] id. Creating with --region into a file that already has an agent adds a [cloud.] entry instead of replacing it. lk agent deploy deploys every region the file lists, or the one passed with --region; the other agent commands prompt for a region when the file lists several. --- cmd/lk/agent.go | 173 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 145 insertions(+), 28 deletions(-) diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index 1358a5e1..be17f1a2 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -189,7 +189,7 @@ var ( regionFlag = &cli.StringFlag{ Name: "region", - Usage: "Region to deploy the agent to. If unset, will deploy to the nearest region.", + Usage: "Region to deploy the agent to. On create, defaults to the nearest region; on deploy, to every region in livekit.toml.", Required: false, } @@ -694,7 +694,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return err } - if configExists && lkConfig.Agent != nil { + if configExists && lkConfig.HasAgent() { out.Statusf("Using agent configuration [%s]", util.Accented(tomlFilename)) } else { lkConfig = config.NewLiveKitTOML(subdomainMatches[1]).WithDefaultAgent() @@ -738,9 +738,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("unable to create agent: %w", err) } agentID := created.AgentId - lkConfig.Agent.ID = agentID - lkConfig.Agent.Name = created.AgentName - if err := lkConfig.SaveTOMLFile(workingDir, tomlFilename); err != nil { + if err := recordCreatedAgent(ctx, cmd, region, agentID, created.AgentName); err != nil { return err } out.Statusf("Created agent with ID [%s]", util.Accented(agentID)) @@ -793,9 +791,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("unable to create agent: %w", err) } - lkConfig.Agent.ID = resp.AgentId - lkConfig.Agent.Name = resp.AgentName - if err := lkConfig.SaveTOMLFile(workingDir, tomlFilename); err != nil { + if err := recordCreatedAgent(ctx, cmd, region, resp.AgentId, resp.AgentName); err != nil { return err } @@ -817,7 +813,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return err } else if viewLogs { out.Status("Tailing runtime logs...safe to exit at any time") - return agentsClient.StreamLogs(ctx, "deploy", lkConfig.Agent.ID, "", os.Stdout, resp.ServerRegions[0]) + return agentsClient.StreamLogs(ctx, "deploy", resp.AgentId, "", os.Stdout, resp.ServerRegions[0]) } } return nil @@ -858,12 +854,12 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { } if configExists && lkConfig.HasAgent() { - agentID = lkConfig.Agent.ID + agentID, err = configuredAgentID(cmd) } else { agentID, err = selectAgent(ctx, cmd, false) - if err != nil { - return err - } + } + if err != nil { + return err } } @@ -888,10 +884,8 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { agent := response.Agents[0] lkConfig := config.NewLiveKitTOML(matches[1]) - lkConfig.Agent = &config.LiveKitTOMLAgentConfig{ - ID: agent.AgentId, - Name: agent.AgentName, - } + lkConfig.Agent = &config.LiveKitTOMLAgentConfig{Name: agent.AgentName} + lkConfig.SetAgentID("", agent.AgentId, "") if err := lkConfig.SaveTOMLFile(workingDir, tomlFilename); err != nil { return err @@ -900,7 +894,7 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { } func deployAgent(ctx context.Context, cmd *cli.Command) error { - agentId, err := getAgentID(ctx, cmd, workingDir, tomlFilename, false) + targets, err := deployTargets(ctx, cmd) if err != nil { return err } @@ -922,10 +916,13 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { imageRef := cmd.String("image") imageTar := cmd.String("image-tar") if imageRef != "" || imageTar != "" { - if err := deployPrebuiltImageTo(buildContext, agentId, imageRef, imageTar, secrets, attrs); err != nil { - return err + for _, t := range targets { + t.announce() + if err := deployPrebuiltImageTo(buildContext, t.id, imageRef, imageTar, secrets, attrs); err != nil { + return err + } + out.Status("Deployed agent") } - out.Status("Deployed agent") return nil } @@ -954,13 +951,126 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { out.Statusf("Using deployment [%s]", util.Accented(agentDeployment)) } - if err := deploySource(buildContext, agentId, secrets, attrs, agentDeployment); err != nil { - return err + for _, t := range targets { + t.announce() + if err := deploySource(buildContext, t.id, secrets, attrs, agentDeployment); err != nil { + return err + } + reportDeployment(ctx, t.id, agentDeployment) } - reportDeployment(ctx, agentId, agentDeployment) return nil } +type deployTarget struct { + region string // "" for a single-region agent + id string +} + +func (t deployTarget) announce() { + if t.region == "" { + out.Statusf("Using agent [%s]", util.Accented(t.id)) + return + } + out.Statusf("Deploying agent [%s] to [%s]", util.Accented(t.id), util.Accented(t.region)) +} + +// deployTargets resolves what `lk agent deploy` deploys: the one agent for +// --region, or every region listed in livekit.toml. +func deployTargets(ctx context.Context, cmd *cli.Command) ([]deployTarget, error) { + configExists, err := requireConfig(workingDir, tomlFilename) + if err != nil && configExists { + return nil, err + } + if !configExists { + id, err := selectAgent(ctx, cmd, false) + if err != nil { + return nil, err + } + return []deployTarget{{id: id}}, nil + } + if !lkConfig.HasAgent() { + return nil, fmt.Errorf("no agent config found in [%s]", tomlFilename) + } + ids := lkConfig.AgentIDs() + if cmd.IsSet("region") || len(ids) <= 1 { + id, err := lkConfig.AgentID(cmd.String("region")) + if err != nil { + return nil, err + } + return []deployTarget{{region: cmd.String("region"), id: id}}, nil + } + targets := make([]deployTarget, 0, len(ids)) + for _, region := range slices.Sorted(maps.Keys(ids)) { + targets = append(targets, deployTarget{region: region, id: ids[region]}) + } + return targets, nil +} + +// configuredAgentID picks one agent from livekit.toml: the only one, the one +// for --region, or an interactive choice when the file lists several regions. +func configuredAgentID(cmd *cli.Command) (string, error) { + ids := lkConfig.AgentIDs() + if cmd.IsSet("region") || len(ids) <= 1 { + return lkConfig.AgentID(cmd.String("region")) + } + if SkipPrompts(cmd) { + return "", fmt.Errorf("non-interactive mode: %s lists %d regions, set --id", tomlFilename, len(ids)) + } + var region string + if err := huh.NewSelect[string](). + Title("Select a region"). + Options(huh.NewOptions(slices.Sorted(maps.Keys(ids))...)...). + Value(®ion). + WithTheme(util.FormTheme()). + Run(); err != nil { + return "", err + } + return ids[region], nil +} + +// recordCreatedAgent saves the new agent to livekit.toml. A create with +// --region into a file that already has an agent adds (or replaces) that +// region's entry; otherwise the new agent replaces the existing one. +func recordCreatedAgent(ctx context.Context, cmd *cli.Command, region, agentID, agentName string) error { + if lkConfig.Agent == nil { + lkConfig.WithDefaultAgent() + } + existing := lkConfig.AgentIDs() + flatID, flat := existing[""] + if len(existing) == 0 || (flat && !cmd.IsSet("region")) { + lkConfig.Cloud = &config.LiveKitTOMLCloudConfig{ID: agentID} + lkConfig.Agent.Name = agentName + return lkConfig.SaveTOMLFile(workingDir, tomlFilename) + } + + existingRegion := "" + if flat { + var err error + if existingRegion, err = agentRegion(ctx, flatID); err != nil { + return err + } + } + lkConfig.SetAgentID(region, agentID, existingRegion) + if lkConfig.Agent.Name == "" { + lkConfig.Agent.Name = agentName + } else if agentName != lkConfig.Agent.Name { + out.Warnf("Cloud named the [%s] agent [%s]; %s keeps [%s]", region, agentName, tomlFilename, lkConfig.Agent.Name) + } + return lkConfig.SaveTOMLFile(workingDir, tomlFilename) +} + +// agentRegion returns the region agentID is deployed in. +func agentRegion(ctx context.Context, agentID string) (string, error) { + resp, err := agentsClient.ListAgents(ctx, &lkproto.ListAgentsRequest{AgentId: agentID}) + if err != nil { + return "", fmt.Errorf("unable to look up region of agent [%s]: %w", agentID, err) + } + if len(resp.Agents) == 0 || len(resp.Agents[0].AgentDeployments) == 0 { + return "", fmt.Errorf("agent [%s] has no deployment; pass its region in %s under [cloud.]", agentID, tomlFilename) + } + return resp.Agents[0].AgentDeployments[0].Region, nil +} + // deployPrebuiltImageTo updates the agent's secrets, if any, then pushes the // prebuilt image to it. func deployPrebuiltImageTo(ctx context.Context, agentID, imageRef, imageTar string, secrets []*lkproto.AgentSecret, attrs map[string]string) error { @@ -1245,9 +1355,13 @@ func updateAgent(ctx context.Context, cmd *cli.Command) error { if !lkConfig.HasAgent() { return fmt.Errorf("no agent config found in [%s]", tomlFilename) } + agentID, err := configuredAgentID(cmd) + if err != nil { + return err + } req := &lkproto.UpdateAgentRequest{ - AgentId: lkConfig.Agent.ID, + AgentId: agentID, } secrets, err := requireSecrets(ctx, cmd, false, true) @@ -1259,7 +1373,7 @@ func updateAgent(ctx context.Context, cmd *cli.Command) error { } var resp *lkproto.UpdateAgentResponse - err = out.Await("Updating agent ["+util.Accented(lkConfig.Agent.ID)+"]", ctx, func(ctx context.Context) error { + err = out.Await("Updating agent ["+util.Accented(agentID)+"]", ctx, func(ctx context.Context) error { var clientErr error resp, clientErr = agentsClient.UpdateAgent(ctx, req) return clientErr @@ -1272,7 +1386,7 @@ func updateAgent(ctx context.Context, cmd *cli.Command) error { } if resp.Success { - out.Statusf("Updated agent [%s]", util.Accented(lkConfig.Agent.ID)) + out.Statusf("Updated agent [%s]", util.Accented(agentID)) err = lkConfig.SaveTOMLFile("", tomlFilename) return err } @@ -1798,7 +1912,10 @@ func getAgentID(ctx context.Context, cmd *cli.Command, agentDir string, tomlFile if !lkConfig.HasAgent() { return "", fmt.Errorf("no agent config found in [%s]", tomlFilename) } - agentID = lkConfig.Agent.ID + agentID, err = configuredAgentID(cmd) + if err != nil { + return "", err + } } else { agentID, err = selectAgent(ctx, cmd, excludeEmptyVersion) if err != nil { From b5e1e6b8f4a1192b287964d5cc7f0eee29a1fa97 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Wed, 23 Sep 2026 09:57:20 -0400 Subject: [PATCH 5/5] agent: drop per-region [cloud.] tables from livekit.toml livekit.toml holds a single [cloud] id. `lk agent create --region` replaces the agent instead of adding a region entry, `lk agent deploy` deploys the one agent, and the other agent commands no longer prompt for a region. --- cmd/lk/agent.go | 224 +++++++++---------------------------- pkg/config/livekit.go | 146 ++++-------------------- pkg/config/livekit_test.go | 65 ++--------- 3 files changed, 84 insertions(+), 351 deletions(-) diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index be17f1a2..9324a70e 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -189,7 +189,7 @@ var ( regionFlag = &cli.StringFlag{ Name: "region", - Usage: "Region to deploy the agent to. On create, defaults to the nearest region; on deploy, to every region in livekit.toml.", + Usage: "Region to deploy the agent to. If unset, will deploy to the nearest region.", Required: false, } @@ -738,7 +738,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("unable to create agent: %w", err) } agentID := created.AgentId - if err := recordCreatedAgent(ctx, cmd, region, agentID, created.AgentName); err != nil { + if err := recordCreatedAgent(agentID, created.AgentName); err != nil { return err } out.Statusf("Created agent with ID [%s]", util.Accented(agentID)) @@ -791,7 +791,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("unable to create agent: %w", err) } - if err := recordCreatedAgent(ctx, cmd, region, resp.AgentId, resp.AgentName); err != nil { + if err := recordCreatedAgent(resp.AgentId, resp.AgentName); err != nil { return err } @@ -854,12 +854,12 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { } if configExists && lkConfig.HasAgent() { - agentID, err = configuredAgentID(cmd) + agentID = lkConfig.AgentID() } else { agentID, err = selectAgent(ctx, cmd, false) - } - if err != nil { - return err + if err != nil { + return err + } } } @@ -885,7 +885,7 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { agent := response.Agents[0] lkConfig := config.NewLiveKitTOML(matches[1]) lkConfig.Agent = &config.LiveKitTOMLAgentConfig{Name: agent.AgentName} - lkConfig.SetAgentID("", agent.AgentId, "") + lkConfig.Cloud = &config.LiveKitTOMLCloudConfig{ID: agent.AgentId} if err := lkConfig.SaveTOMLFile(workingDir, tomlFilename); err != nil { return err @@ -893,8 +893,19 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { return nil } +// recordCreatedAgent saves the new agent's id and Cloud-assigned name to +// livekit.toml, replacing any agent already there. +func recordCreatedAgent(agentID, agentName string) error { + if lkConfig.Agent == nil { + lkConfig.WithDefaultAgent() + } + lkConfig.Agent.Name = agentName + lkConfig.Cloud = &config.LiveKitTOMLCloudConfig{ID: agentID} + return lkConfig.SaveTOMLFile(workingDir, tomlFilename) +} + func deployAgent(ctx context.Context, cmd *cli.Command) error { - targets, err := deployTargets(ctx, cmd) + agentId, err := getAgentID(ctx, cmd, workingDir, tomlFilename, false) if err != nil { return err } @@ -916,13 +927,25 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { imageRef := cmd.String("image") imageTar := cmd.String("image-tar") if imageRef != "" || imageTar != "" { - for _, t := range targets { - t.announce() - if err := deployPrebuiltImageTo(buildContext, t.id, imageRef, imageTar, secrets, attrs); err != nil { - return err + if len(secrets) > 0 { + resp, err := agentsClient.UpdateAgentSecrets(buildContext, &lkproto.UpdateAgentSecretsRequest{ + AgentId: agentId, + Secrets: secrets, + }) + if err != nil { + if twerr, ok := err.(twirp.Error); ok { + return fmt.Errorf("unable to update agent secrets: %s", twerr.Msg()) + } + return fmt.Errorf("unable to update agent secrets: %w", err) + } + if !resp.Success { + return fmt.Errorf("failed to update agent secrets: %s", resp.Message) } - out.Status("Deployed agent") } + if err := deployPrebuiltImage(buildContext, agentId, imageRef, imageTar, attrs); err != nil { + return fmt.Errorf("unable to deploy prebuilt image: %w", err) + } + out.Status("Deployed agent") return nil } @@ -951,155 +974,8 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { out.Statusf("Using deployment [%s]", util.Accented(agentDeployment)) } - for _, t := range targets { - t.announce() - if err := deploySource(buildContext, t.id, secrets, attrs, agentDeployment); err != nil { - return err - } - reportDeployment(ctx, t.id, agentDeployment) - } - return nil -} - -type deployTarget struct { - region string // "" for a single-region agent - id string -} - -func (t deployTarget) announce() { - if t.region == "" { - out.Statusf("Using agent [%s]", util.Accented(t.id)) - return - } - out.Statusf("Deploying agent [%s] to [%s]", util.Accented(t.id), util.Accented(t.region)) -} - -// deployTargets resolves what `lk agent deploy` deploys: the one agent for -// --region, or every region listed in livekit.toml. -func deployTargets(ctx context.Context, cmd *cli.Command) ([]deployTarget, error) { - configExists, err := requireConfig(workingDir, tomlFilename) - if err != nil && configExists { - return nil, err - } - if !configExists { - id, err := selectAgent(ctx, cmd, false) - if err != nil { - return nil, err - } - return []deployTarget{{id: id}}, nil - } - if !lkConfig.HasAgent() { - return nil, fmt.Errorf("no agent config found in [%s]", tomlFilename) - } - ids := lkConfig.AgentIDs() - if cmd.IsSet("region") || len(ids) <= 1 { - id, err := lkConfig.AgentID(cmd.String("region")) - if err != nil { - return nil, err - } - return []deployTarget{{region: cmd.String("region"), id: id}}, nil - } - targets := make([]deployTarget, 0, len(ids)) - for _, region := range slices.Sorted(maps.Keys(ids)) { - targets = append(targets, deployTarget{region: region, id: ids[region]}) - } - return targets, nil -} - -// configuredAgentID picks one agent from livekit.toml: the only one, the one -// for --region, or an interactive choice when the file lists several regions. -func configuredAgentID(cmd *cli.Command) (string, error) { - ids := lkConfig.AgentIDs() - if cmd.IsSet("region") || len(ids) <= 1 { - return lkConfig.AgentID(cmd.String("region")) - } - if SkipPrompts(cmd) { - return "", fmt.Errorf("non-interactive mode: %s lists %d regions, set --id", tomlFilename, len(ids)) - } - var region string - if err := huh.NewSelect[string](). - Title("Select a region"). - Options(huh.NewOptions(slices.Sorted(maps.Keys(ids))...)...). - Value(®ion). - WithTheme(util.FormTheme()). - Run(); err != nil { - return "", err - } - return ids[region], nil -} - -// recordCreatedAgent saves the new agent to livekit.toml. A create with -// --region into a file that already has an agent adds (or replaces) that -// region's entry; otherwise the new agent replaces the existing one. -func recordCreatedAgent(ctx context.Context, cmd *cli.Command, region, agentID, agentName string) error { - if lkConfig.Agent == nil { - lkConfig.WithDefaultAgent() - } - existing := lkConfig.AgentIDs() - flatID, flat := existing[""] - if len(existing) == 0 || (flat && !cmd.IsSet("region")) { - lkConfig.Cloud = &config.LiveKitTOMLCloudConfig{ID: agentID} - lkConfig.Agent.Name = agentName - return lkConfig.SaveTOMLFile(workingDir, tomlFilename) - } - - existingRegion := "" - if flat { - var err error - if existingRegion, err = agentRegion(ctx, flatID); err != nil { - return err - } - } - lkConfig.SetAgentID(region, agentID, existingRegion) - if lkConfig.Agent.Name == "" { - lkConfig.Agent.Name = agentName - } else if agentName != lkConfig.Agent.Name { - out.Warnf("Cloud named the [%s] agent [%s]; %s keeps [%s]", region, agentName, tomlFilename, lkConfig.Agent.Name) - } - return lkConfig.SaveTOMLFile(workingDir, tomlFilename) -} - -// agentRegion returns the region agentID is deployed in. -func agentRegion(ctx context.Context, agentID string) (string, error) { - resp, err := agentsClient.ListAgents(ctx, &lkproto.ListAgentsRequest{AgentId: agentID}) - if err != nil { - return "", fmt.Errorf("unable to look up region of agent [%s]: %w", agentID, err) - } - if len(resp.Agents) == 0 || len(resp.Agents[0].AgentDeployments) == 0 { - return "", fmt.Errorf("agent [%s] has no deployment; pass its region in %s under [cloud.]", agentID, tomlFilename) - } - return resp.Agents[0].AgentDeployments[0].Region, nil -} - -// deployPrebuiltImageTo updates the agent's secrets, if any, then pushes the -// prebuilt image to it. -func deployPrebuiltImageTo(ctx context.Context, agentID, imageRef, imageTar string, secrets []*lkproto.AgentSecret, attrs map[string]string) error { - if len(secrets) > 0 { - resp, err := agentsClient.UpdateAgentSecrets(ctx, &lkproto.UpdateAgentSecretsRequest{ - AgentId: agentID, - Secrets: secrets, - }) - if err != nil { - if twerr, ok := err.(twirp.Error); ok { - return fmt.Errorf("unable to update agent secrets: %s", twerr.Msg()) - } - return fmt.Errorf("unable to update agent secrets: %w", err) - } - if !resp.Success { - return fmt.Errorf("failed to update agent secrets: %s", resp.Message) - } - } - if err := deployPrebuiltImage(ctx, agentID, imageRef, imageTar, attrs); err != nil { - return fmt.Errorf("unable to deploy prebuilt image: %w", err) - } - return nil -} - -// deploySource builds workingDir on the server and deploys it to the agent. -// A nil error after Ctrl-C means the deploy continues server-side. -func deploySource(ctx context.Context, agentID string, secrets []*lkproto.AgentSecret, attrs map[string]string, agentDeployment string) error { excludeFiles := []string{fmt.Sprintf("**/%s", config.LiveKitTOMLFile)} - if err := agentsClient.DeployAgentV2(ctx, agentID, os.DirFS(workingDir), secrets, attrs, agentDeployment, excludeFiles, os.Stderr); err != nil { + if err := agentsClient.DeployAgentV2(buildContext, agentId, os.DirFS(workingDir), secrets, attrs, agentDeployment, excludeFiles, os.Stderr); err != nil { if errors.Is(err, context.Canceled) { // The client disconnected (Ctrl-C). Deploys are durable — the build runs to // completion and deploys on the server regardless, so this is not a failure. @@ -1111,9 +987,23 @@ func deploySource(ctx context.Context, agentID string, secrets []*lkproto.AgentS } return fmt.Errorf("unable to deploy agent: %w", err) } + + reportDeployment(ctx, agentId, agentDeployment) return nil } +// reportDeployment prints a summary of a completed deployment — the agent name, +// the target deployment, and links to the agent details page and the agent +// console for the deployment. It resolves the name with a single ListAgents +// call; on any failure it falls back to the minimal status line so a successful +// deploy is never reported as a failure. +// +// The version is intentionally omitted: the deploy API doesn't return the new +// version, the agent-level version reflects the production deployment (wrong +// for a non-production deploy), and the per-deployment version isn't populated +// until the agent is scraped. There is no source that is both correct and ready +// synchronously at deploy time, so reporting it would risk showing the wrong +// version. func reportDeployment(ctx context.Context, agentID, deployment string) { targetDeployment := deployment if targetDeployment == "" { @@ -1355,10 +1245,7 @@ func updateAgent(ctx context.Context, cmd *cli.Command) error { if !lkConfig.HasAgent() { return fmt.Errorf("no agent config found in [%s]", tomlFilename) } - agentID, err := configuredAgentID(cmd) - if err != nil { - return err - } + agentID := lkConfig.AgentID() req := &lkproto.UpdateAgentRequest{ AgentId: agentID, @@ -1912,10 +1799,7 @@ func getAgentID(ctx context.Context, cmd *cli.Command, agentDir string, tomlFile if !lkConfig.HasAgent() { return "", fmt.Errorf("no agent config found in [%s]", tomlFilename) } - agentID, err = configuredAgentID(cmd) - if err != nil { - return "", err - } + agentID = lkConfig.AgentID() } else { agentID, err = selectAgent(ctx, cmd, excludeEmptyVersion) if err != nil { diff --git a/pkg/config/livekit.go b/pkg/config/livekit.go index 148ce0f3..6890af7b 100644 --- a/pkg/config/livekit.go +++ b/pkg/config/livekit.go @@ -44,7 +44,7 @@ type AgentTOML struct { type LiveKitTOML struct { Project *LiveKitTOMLProjectConfig `toml:"project"` // Required Agent *LiveKitTOMLAgentConfig `toml:"agent"` - Cloud *LiveKitTOMLCloudConfig `toml:"-"` + Cloud *LiveKitTOMLCloudConfig `toml:"cloud,omitempty"` } type LiveKitTOMLProjectConfig struct { @@ -59,73 +59,9 @@ type LiveKitTOMLAgentConfig struct { Name string `toml:"name"` } -// LiveKitTOMLCloudConfig holds the Cloud Agents id(s) for the agent. ID is the -// id of a single-region agent; Regions maps region code to id when the agent -// is deployed per region. Exactly one of the two is populated. +// LiveKitTOMLCloudConfig identifies the agent on LiveKit Cloud. type LiveKitTOMLCloudConfig struct { - ID string - Regions map[string]string -} - -// tomlFile is the on-disk shape: [cloud] is "id" and/or one sub-table per -// region, which a struct with fixed fields cannot express. -type tomlFile struct { - Project *LiveKitTOMLProjectConfig `toml:"project"` - Agent *LiveKitTOMLAgentConfig `toml:"agent"` - Cloud map[string]any `toml:"cloud,omitempty"` -} - -func (c *LiveKitTOML) toFile() *tomlFile { - f := &tomlFile{Project: c.Project, Agent: c.Agent} - if c.Cloud == nil { - return f - } - f.Cloud = map[string]any{} - if c.Cloud.ID != "" { - f.Cloud["id"] = c.Cloud.ID - } - for region, id := range c.Cloud.Regions { - f.Cloud[region] = map[string]string{"id": id} - } - return f -} - -func (f *tomlFile) toConfig() (*LiveKitTOML, error) { - c := &LiveKitTOML{Project: f.Project, Agent: f.Agent} - if c.Agent != nil && c.Agent.ID != "" { - c.Cloud = &LiveKitTOMLCloudConfig{ID: c.Agent.ID} - c.Agent.ID = "" - } - if len(f.Cloud) == 0 { - return c, nil - } - if c.Cloud == nil { - c.Cloud = &LiveKitTOMLCloudConfig{} - } - for key, value := range f.Cloud { - switch v := value.(type) { - case string: - if key != "id" { - return nil, fmt.Errorf("[cloud] %s: unknown key: %w", key, ErrInvalidConfig) - } - c.Cloud.ID = v - case map[string]any: - id, _ := v["id"].(string) - if id == "" { - return nil, fmt.Errorf("[cloud.%s] id is required: %w", key, ErrInvalidConfig) - } - if c.Cloud.Regions == nil { - c.Cloud.Regions = map[string]string{} - } - c.Cloud.Regions[key] = id - default: - return nil, fmt.Errorf("[cloud] %s: unexpected value: %w", key, ErrInvalidConfig) - } - } - if c.Cloud.ID != "" && len(c.Cloud.Regions) > 0 { - return nil, fmt.Errorf("[cloud] id and [cloud.] tables are mutually exclusive: %w", ErrInvalidConfig) - } - return c, nil + ID string `toml:"id"` } func NewLiveKitTOML(forSubdomain string) *LiveKitTOML { @@ -145,59 +81,12 @@ func (c *LiveKitTOML) HasAgent() bool { return c.Agent != nil || c.Cloud != nil } -// AgentIDs returns the Cloud Agents ids keyed by region; a single-region id -// is keyed by "". -func (c *LiveKitTOML) AgentIDs() map[string]string { +// AgentID returns the Cloud Agents id, or "" for an agent not on Cloud. +func (c *LiveKitTOML) AgentID() string { if c.Cloud == nil { - return nil + return "" } - if c.Cloud.ID != "" { - return map[string]string{"": c.Cloud.ID} - } - return c.Cloud.Regions -} - -// AgentID returns the id deployed to region, or the only id when region is "". -func (c *LiveKitTOML) AgentID(region string) (string, error) { - ids := c.AgentIDs() - if len(ids) == 0 { - return "", fmt.Errorf("no agent id in [cloud]: %w", ErrInvalidConfig) - } - if region == "" { - if len(ids) > 1 { - return "", fmt.Errorf("%s lists %d regions; pass --region: %w", LiveKitTOMLFile, len(ids), ErrInvalidConfig) - } - for _, id := range ids { - return id, nil - } - } - if id, ok := ids[region]; ok { - return id, nil - } - if id, ok := ids[""]; ok { - return id, nil - } - return "", fmt.Errorf("no agent id for region %q in %s: %w", region, LiveKitTOMLFile, ErrInvalidConfig) -} - -// SetAgentID records id for region. The layout stays flat until a second -// region is added, at which point the existing id is keyed by existingRegion. -func (c *LiveKitTOML) SetAgentID(region, id, existingRegion string) { - if c.Cloud == nil { - c.Cloud = &LiveKitTOMLCloudConfig{} - } - if len(c.Cloud.Regions) == 0 && (c.Cloud.ID == "" || region == "" || region == existingRegion) { - c.Cloud.ID = id - return - } - if c.Cloud.Regions == nil { - c.Cloud.Regions = map[string]string{} - } - if c.Cloud.ID != "" { - c.Cloud.Regions[existingRegion] = c.Cloud.ID - c.Cloud.ID = "" - } - c.Cloud.Regions[region] = id + return c.Cloud.ID } func (c *LiveKitTOML) SaveTOMLFile(dir string, tomlFileName string) error { @@ -207,7 +96,7 @@ func (c *LiveKitTOML) SaveTOMLFile(dir string, tomlFileName string) error { } defer f.Close() encoder := toml.NewEncoder(f) - if err := encoder.Encode(c.toFile()); err != nil { + if err := encoder.Encode(c); err != nil { return fmt.Errorf("error encoding TOML: %w", err) } util.Statusf("Saving config file [%s]", util.Accented(tomlFileName)) @@ -222,21 +111,26 @@ func LoadTOMLFile(dir string, tomlFileName string) (*LiveKitTOML, bool, error) { return nil, !errors.Is(err, fs.ErrNotExist), err } - var file tomlFile - if _, err := toml.DecodeFile(path, &file); err != nil { + var config LiveKitTOML + if _, err := toml.DecodeFile(path, &config); err != nil { return nil, true, err } - if file.Project == nil { + if config.Project == nil { // Attempt to decode old agent config var oldConfig AgentTOML if _, err := toml.DecodeFile(path, &oldConfig); err != nil { return nil, true, err } - file.Project = &LiveKitTOMLProjectConfig{ + config.Project = &LiveKitTOMLProjectConfig{ Subdomain: oldConfig.ProjectSubdomain, } - file.Agent = &LiveKitTOMLAgentConfig{} + config.Agent = &LiveKitTOMLAgentConfig{} + } + if config.Agent != nil && config.Agent.ID != "" { + if config.Cloud == nil { + config.Cloud = &LiveKitTOMLCloudConfig{ID: config.Agent.ID} + } + config.Agent.ID = "" } - config, err := file.toConfig() - return config, true, err + return &config, true, nil } diff --git a/pkg/config/livekit_test.go b/pkg/config/livekit_test.go index 4e8e5a77..d8504913 100644 --- a/pkg/config/livekit_test.go +++ b/pkg/config/livekit_test.go @@ -43,10 +43,10 @@ name = "my-agent" require.NoError(t, err) require.Equal(t, "my-agent", cfg.Agent.Name) require.Empty(t, cfg.Agent.ID) - require.Equal(t, map[string]string{"": "CA_legacy"}, cfg.AgentIDs()) + require.Equal(t, "CA_legacy", cfg.AgentID()) } -func TestLoadTOMLFile_CloudRegions(t *testing.T) { +func TestLoadTOMLFile_CloudID(t *testing.T) { dir := writeTOML(t, ` [project] subdomain = "proj" @@ -54,69 +54,19 @@ subdomain = "proj" [agent] name = "my-agent" -[cloud.us-east] +[cloud] id = "CA_a" - -[cloud.eu-central] -id = "CA_b" `) cfg, _, err := LoadTOMLFile(dir, LiveKitTOMLFile) require.NoError(t, err) - require.Equal(t, map[string]string{"us-east": "CA_a", "eu-central": "CA_b"}, cfg.AgentIDs()) - - id, err := cfg.AgentID("eu-central") - require.NoError(t, err) - require.Equal(t, "CA_b", id) - - _, err = cfg.AgentID("") - require.ErrorIs(t, err, ErrInvalidConfig) - _, err = cfg.AgentID("ap-south") - require.ErrorIs(t, err, ErrInvalidConfig) -} - -func TestLoadTOMLFile_CloudIDAndRegionsAreExclusive(t *testing.T) { - dir := writeTOML(t, ` -[project] -subdomain = "proj" - -[cloud] -id = "CA_a" - -[cloud.us-east] -id = "CA_b" -`) - _, _, err := LoadTOMLFile(dir, LiveKitTOMLFile) - require.ErrorIs(t, err, ErrInvalidConfig) + require.Equal(t, "CA_a", cfg.AgentID()) } func TestSaveTOMLFile_RoundTrip(t *testing.T) { dir := t.TempDir() cfg := NewLiveKitTOML("proj").WithDefaultAgent() cfg.Agent.Name = "my-agent" - cfg.SetAgentID("us-east", "CA_a", "") - require.Equal(t, "CA_a", cfg.Cloud.ID, "a single region stays flat") - - cfg.SetAgentID("eu-central", "CA_b", "us-east") - require.Empty(t, cfg.Cloud.ID) - require.Equal(t, map[string]string{"us-east": "CA_a", "eu-central": "CA_b"}, cfg.Cloud.Regions) - - require.NoError(t, cfg.SaveTOMLFile(dir, LiveKitTOMLFile)) - raw, err := os.ReadFile(filepath.Join(dir, LiveKitTOMLFile)) - require.NoError(t, err) - require.Contains(t, string(raw), "[cloud.us-east]") - require.NotContains(t, string(raw), "[agent]\n id") - - loaded, _, err := LoadTOMLFile(dir, LiveKitTOMLFile) - require.NoError(t, err) - require.Equal(t, cfg.Agent.Name, loaded.Agent.Name) - require.Equal(t, cfg.AgentIDs(), loaded.AgentIDs()) -} - -func TestSaveTOMLFile_FlatCloudLayout(t *testing.T) { - dir := t.TempDir() - cfg := NewLiveKitTOML("proj").WithDefaultAgent() - cfg.Agent.Name = "my-agent" - cfg.SetAgentID("", "CA_a", "") + cfg.Cloud = &LiveKitTOMLCloudConfig{ID: "CA_a"} require.NoError(t, cfg.SaveTOMLFile(dir, LiveKitTOMLFile)) raw, err := os.ReadFile(filepath.Join(dir, LiveKitTOMLFile)) require.NoError(t, err) @@ -129,4 +79,9 @@ func TestSaveTOMLFile_FlatCloudLayout(t *testing.T) { [cloud] id = "CA_a" `, string(raw)) + + loaded, _, err := LoadTOMLFile(dir, LiveKitTOMLFile) + require.NoError(t, err) + require.Equal(t, cfg.Agent.Name, loaded.Agent.Name) + require.Equal(t, cfg.AgentID(), loaded.AgentID()) }