Skip to content

Commit 93e6c18

Browse files
authored
feat(agents): support attributes on deploy/list, json on all cmds (#887)
1 parent e6e30a5 commit 93e6c18

8 files changed

Lines changed: 522 additions & 97 deletions

File tree

autocomplete/fish_autocomplete

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma
8080
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from config' -f -l help -s h -d 'show help'
8181
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from config; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command'
8282
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'deploy' -d 'Deploy a new version of the agent'
83+
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -f -l attributes -r -d '`JSON` literal or file path containing an object of string key-value pairs. Use "-" to read from stdin.'
84+
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -f -l attribute -r -d '`KEY=VALUE` attribute pair, may be repeated. Merged with --attributes, taking precedence on conflicting keys.'
8385
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -f -l secrets -r -d 'KEY=VALUE comma separated secrets. These will be injected as environment variables into the agent. These take precedence over secrets-file.'
8486
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -l secrets-file -r -d '`FILE` containing secret KEY=VALUE pairs, one per line. These will be injected as environment variables into the agent.'
8587
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy' -f -l secret-mount -r -d 'Local path to a secret file to be mounted on agent environment'
@@ -91,6 +93,7 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma
9193
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from deploy; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command'
9294
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'status' -d 'Get the status of an agent'
9395
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from status' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.'
96+
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from status' -f -l json -s j -d 'Output as JSON'
9497
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from status' -f -l help -s h -d 'show help'
9598
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from status; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command'
9699
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'update' -d 'Update an agent metadata and secrets. This will restart the agent.'
@@ -120,14 +123,19 @@ complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcomma
120123
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from delete destroy; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command'
121124
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'versions' -d 'List versions of an agent'
122125
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from versions' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.'
126+
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from versions' -f -l attribute -r -d '`KEY=VALUE` attribute pair, may be repeated. Merged with --attributes, taking precedence on conflicting keys.'
127+
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from versions' -f -l attributes -r -d '`JSON` literal or file path containing an object of string key-value pairs. Use "-" to read from stdin.'
128+
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from versions' -f -l json -s j -d 'Output as JSON'
123129
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from versions' -f -l help -s h -d 'show help'
124130
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from versions; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command'
125131
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'list' -d 'List all LiveKit Cloud Agents'
126132
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from list' -f -l id -r -d '`IDs` of agent(s)'
133+
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from list' -f -l json -s j -d 'Output as JSON'
127134
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from list' -f -l help -s h -d 'show help'
128135
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from list; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command'
129136
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'secrets' -d 'List secrets for an agent'
130137
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from secrets' -f -l id -r -d '`ID` of the agent. If unset, and the livekit.toml file is present, will use the id found there.'
138+
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from secrets' -f -l json -s j -d 'Output as JSON'
131139
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from secrets' -f -l help -s h -d 'show help'
132140
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from secrets; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command'
133141
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'update-secrets' -d 'Update secrets for an agent, will cause a re-start of the agent.'

cmd/lk/agent.go

Lines changed: 113 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ package main
1616

1717
import (
1818
"context"
19+
"encoding/json"
1920
"errors"
2021
"fmt"
2122
"io"
23+
"maps"
2224
"os"
2325
"path/filepath"
2426
"regexp"
@@ -62,6 +64,18 @@ var (
6264
Required: false,
6365
}
6466

67+
attributesFlag = &cli.StringFlag{
68+
Name: "attributes",
69+
Usage: "`JSON` literal or file path containing an object of string key-value pairs. Use \"-\" to read from stdin.",
70+
Required: false,
71+
}
72+
73+
attributeFlag = &cli.StringSliceFlag{
74+
Name: "attribute",
75+
Usage: "`KEY=VALUE` attribute pair, may be repeated. Merged with --attributes, taking precedence on conflicting keys.",
76+
Required: false,
77+
}
78+
6579
secretsFileFlag = &cli.StringFlag{
6680
Name: "secrets-file",
6781
Usage: "`FILE` containing secret KEY=VALUE pairs, one per line. These will be injected as environment variables into the agent.",
@@ -221,6 +235,8 @@ var (
221235
Before: createAgentClient,
222236
Action: deployAgent,
223237
Flags: []cli.Flag{
238+
attributesFlag,
239+
attributeFlag,
224240
secretsFlag,
225241
secretsFileFlag,
226242
secretsMountFlag,
@@ -242,6 +258,7 @@ var (
242258
Action: getAgentStatus,
243259
Flags: []cli.Flag{
244260
idFlag(false),
261+
jsonFlag,
245262
},
246263
ArgsUsage: "[working-dir]",
247264
},
@@ -317,6 +334,9 @@ var (
317334
Action: listAgentVersions,
318335
Flags: []cli.Flag{
319336
idFlag(false),
337+
attributeFlag,
338+
attributesFlag,
339+
jsonFlag,
320340
},
321341
ArgsUsage: "[working-dir]",
322342
},
@@ -327,6 +347,7 @@ var (
327347
Before: createAgentClient,
328348
Flags: []cli.Flag{
329349
idSliceFlag,
350+
jsonFlag,
330351
},
331352
},
332353
{
@@ -336,6 +357,7 @@ var (
336357
Action: listAgentSecrets,
337358
Flags: []cli.Flag{
338359
idFlag(false),
360+
jsonFlag,
339361
},
340362
ArgsUsage: "[working-dir]",
341363
},
@@ -755,6 +777,11 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error {
755777
buildContext, cancel := context.WithTimeout(ctx, buildTimeout)
756778
defer cancel()
757779

780+
attrs, err := resolveAttributes(cmd)
781+
if err != nil {
782+
return err
783+
}
784+
758785
secrets, err := requireSecrets(ctx, cmd, false, true)
759786
if err != nil {
760787
return err
@@ -806,7 +833,7 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error {
806833
}
807834

808835
excludeFiles := []string{fmt.Sprintf("**/%s", config.LiveKitTOMLFile)}
809-
if err := agentsClient.DeployAgent(buildContext, agentId, os.DirFS(workingDir), secrets, excludeFiles, os.Stderr); err != nil {
836+
if err := agentsClient.DeployAgent(buildContext, agentId, os.DirFS(workingDir), secrets, attrs, excludeFiles, os.Stderr); err != nil {
810837
if twerr, ok := err.(twirp.Error); ok {
811838
return fmt.Errorf("unable to deploy agent: %s", twerr.Msg())
812839
}
@@ -876,6 +903,11 @@ func getAgentStatus(ctx context.Context, cmd *cli.Command) error {
876903
return fmt.Errorf("no agents found")
877904
}
878905

906+
if cmd.Bool("json") {
907+
util.PrintJSON(res)
908+
return nil
909+
}
910+
879911
var rows [][]string
880912
for _, agent := range res.Agents {
881913
for _, regionalAgent := range agent.AgentDeployments {
@@ -1085,6 +1117,39 @@ func deleteAgent(ctx context.Context, cmd *cli.Command) error {
10851117
return nil
10861118
}
10871119

1120+
// resolveAttributes merges attribute inputs from the --attributes JSON flag
1121+
// (literal, file path, or "-" for stdin) and the repeatable --attribute
1122+
// key=value flag. The key=value pairs take precedence over the JSON object on
1123+
// conflicting keys. Returns nil when neither flag is set.
1124+
func resolveAttributes(cmd *cli.Command) (map[string]string, error) {
1125+
attrs := map[string]string{}
1126+
if cmd.IsSet(attributesFlag.Name) {
1127+
if _, err := ReadJSONFileOrLiteral(cmd.String(attributesFlag.Name), &attrs); err != nil {
1128+
return nil, err
1129+
}
1130+
}
1131+
pairs, err := parseKeyValuePairs(cmd, attributeFlag.Name)
1132+
if err != nil {
1133+
return nil, err
1134+
}
1135+
maps.Copy(attrs, pairs)
1136+
if len(attrs) == 0 {
1137+
return nil, nil
1138+
}
1139+
return attrs, nil
1140+
}
1141+
1142+
// attributesMatch reports whether attrs contains every key-value pair in
1143+
// filter. Extra keys in attrs are allowed, so the match is inclusive.
1144+
func attributesMatch(attrs, filter map[string]string) bool {
1145+
for k, want := range filter {
1146+
if got, ok := attrs[k]; !ok || got != want {
1147+
return false
1148+
}
1149+
}
1150+
return true
1151+
}
1152+
10881153
func listAgentVersions(ctx context.Context, cmd *cli.Command) error {
10891154
agentID, err := getAgentID(ctx, cmd, workingDir, tomlFilename, false)
10901155
if err != nil {
@@ -1103,11 +1168,28 @@ func listAgentVersions(ctx context.Context, cmd *cli.Command) error {
11031168
return fmt.Errorf("unable to list agent versions: %w", err)
11041169
}
11051170

1171+
// Filter to versions containing all requested attributes. Extra attributes
1172+
// on a version are allowed; the filter is inclusive, not exclusive.
1173+
attrFilter, err := resolveAttributes(cmd)
1174+
if err != nil {
1175+
return err
1176+
}
1177+
if len(attrFilter) > 0 {
1178+
versions.Versions = slices.DeleteFunc(versions.Versions, func(v *lkproto.AgentVersion) bool {
1179+
return !attributesMatch(v.Attributes, attrFilter)
1180+
})
1181+
}
1182+
11061183
// Sort versions by created date descending
11071184
slices.SortFunc(versions.Versions, func(a, b *lkproto.AgentVersion) int {
11081185
return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime())
11091186
})
11101187

1188+
if cmd.Bool("json") {
1189+
util.PrintJSON(versions)
1190+
return nil
1191+
}
1192+
11111193
showDigest := false
11121194
for _, v := range versions.Versions {
11131195
if v.Attributes["image_digest"] != "" {
@@ -1116,17 +1198,22 @@ func listAgentVersions(ctx context.Context, cmd *cli.Command) error {
11161198
}
11171199
}
11181200

1119-
headers := []string{"Version", "Current", "Status", "Created At", "Deployed At"}
1201+
headers := []string{"Version", "Current", "Status", "Attributes", "Created At", "Deployed At"}
11201202
if showDigest {
11211203
headers = append(headers, "Digest")
11221204
}
11231205
table := util.CreateTable().Headers(headers...)
11241206

11251207
for _, version := range versions.Versions {
1208+
attrs, err := json.Marshal(version.Attributes)
1209+
if err != nil || len(version.Attributes) == 0 {
1210+
attrs = []byte("--")
1211+
}
11261212
row := []string{
11271213
version.Version,
11281214
fmt.Sprintf("%t", version.Current),
11291215
version.Status,
1216+
string(attrs),
11301217
version.CreatedAt.AsTime().Format(time.RFC3339),
11311218
version.DeployedAt.AsTime().Format(time.RFC3339),
11321219
}
@@ -1169,15 +1256,20 @@ func listAgents(ctx context.Context, cmd *cli.Command) error {
11691256
items = agents.Agents
11701257
}
11711258

1259+
slices.SortFunc(items, func(a, b *lkproto.AgentInfo) int {
1260+
return b.DeployedAt.AsTime().Compare(a.DeployedAt.AsTime())
1261+
})
1262+
1263+
if cmd.Bool("json") {
1264+
util.PrintJSON(&lkproto.ListAgentsResponse{Agents: items})
1265+
return nil
1266+
}
1267+
11721268
if len(items) == 0 {
11731269
out.Status("No agents found")
11741270
return nil
11751271
}
11761272

1177-
slices.SortFunc(items, func(a, b *lkproto.AgentInfo) int {
1178-
return b.DeployedAt.AsTime().Compare(a.DeployedAt.AsTime())
1179-
})
1180-
11811273
var rows [][]string
11821274
for _, agent := range items {
11831275
var regions []string
@@ -1219,15 +1311,25 @@ func listAgentSecrets(ctx context.Context, cmd *cli.Command) error {
12191311
return fmt.Errorf("unable to list agent secrets: %w", err)
12201312
}
12211313

1222-
// TODO (steveyoon): show secret.Kind.String() once cloud-agents is released
1223-
table := util.CreateTable().
1224-
Headers("Name", "Created At", "Updated At")
1225-
1314+
// NOTE: Maybe these should be omitted on the server side?
1315+
visible := make([]*lkproto.AgentSecret, 0, len(secrets.Secrets))
12261316
for _, secret := range secrets.Secrets {
1227-
// NOTE: Maybe these should be omitted on the server side?
12281317
if slices.Contains(ignoredSecrets, secret.Name) {
12291318
continue
12301319
}
1320+
visible = append(visible, secret)
1321+
}
1322+
1323+
if cmd.Bool("json") {
1324+
util.PrintJSON(&lkproto.ListAgentSecretsResponse{Secrets: visible})
1325+
return nil
1326+
}
1327+
1328+
// TODO (steveyoon): show secret.Kind.String() once cloud-agents is released
1329+
table := util.CreateTable().
1330+
Headers("Name", "Created At", "Updated At")
1331+
1332+
for _, secret := range visible {
12311333
table.Row(secret.Name, secret.CreatedAt.AsTime().Format(time.RFC3339), secret.UpdatedAt.AsTime().Format(time.RFC3339))
12321334
}
12331335

0 commit comments

Comments
 (0)