diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index e6745d0a..0d06d18a 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "io" "os" "path/filepath" "regexp" @@ -26,6 +27,9 @@ import ( "time" "github.com/charmbracelet/huh" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/crane" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/twitchtv/twirp" "github.com/urfave/cli/v3" @@ -97,6 +101,16 @@ var ( Required: false, } + agentPrebuiltImageFlag = &cli.StringFlag{ + Name: "image", + Usage: "Pre-built image from the local Docker daemon (e.g. myimage:latest). Requires Docker.", + } + + agentPrebuiltImageTarFlag = &cli.StringFlag{ + Name: "image-tar", + Usage: "Pre-built image from an OCI tar file (e.g. ./image.tar). No Docker daemon required.", + } + skipSDKCheckFlag = &cli.BoolFlag{ Name: "skip-sdk-check", Required: false, @@ -167,6 +181,8 @@ var ( silentFlag, regionFlag, skipSDKCheckFlag, + agentPrebuiltImageFlag, + agentPrebuiltImageTarFlag, }, // NOTE: since secrets may contain commas, or indeed any special character we might want to treat as a flag separator, // we disable it entirely here and require multiple --secrets flags to be used. @@ -212,6 +228,8 @@ var ( regionFlag, ignoreEmptySecretsFlag, skipSDKCheckFlag, + agentPrebuiltImageFlag, + agentPrebuiltImageTarFlag, }, // NOTE: since secrets may contain commas, or indeed any special character we might want to treat as a flag separator, // we disable it entirely here and require multiple --secrets flags to be used. @@ -468,7 +486,6 @@ func initAgent(ctx context.Context, cmd *cli.Command) error { fmt.Println("Creating sandbox app...") fmt.Printf("Created sandbox app [%s]\n", util.Accented(sandboxID)) } - } // Run template bootstrap @@ -559,24 +576,6 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { return err } - projectType, err := agentfs.DetectProjectType(os.DirFS(workingDir)) - if err != nil { - return fmt.Errorf("unable to determine agent language: %w, please navigate to a directory containing an agent written in a supported language", err) - } - fmt.Printf("Detected agent language [%s]\n", util.Accented(string(projectType))) - - if err := requireDockerfile(ctx, cmd, workingDir, projectType, settingsMap); err != nil { - return err - } - - if err := agentfs.CheckSDKVersion(workingDir, projectType, settingsMap); err != nil { - if cmd.Bool("skip-sdk-check") { - fmt.Printf("Error checking SDK version: %v, skipping...\n", err) - } else { - return err - } - } - region := cmd.String("region") if region == "" { availableRegionsStr, ok := settingsMap["available_regions"] @@ -608,6 +607,44 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { buildContext, cancel := context.WithTimeout(ctx, buildTimeout) defer cancel() regions := []string{region} + + // --image or --image-tar: register the agent record then push the prebuilt image + imageRef := cmd.String("image") + imageTar := cmd.String("image-tar") + if imageRef != "" || imageTar != "" { + agentID, err := agentsClient.RegisterAgent(buildContext, secrets, 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) + } + lkConfig.Agent.ID = agentID + if err := lkConfig.SaveTOMLFile(workingDir, tomlFilename); err != nil { + return err + } + fmt.Printf("Created agent with ID [%s]\n", util.Accented(agentID)) + return deployPrebuiltImage(buildContext, agentID, imageRef, imageTar) + } + + projectType, err := agentfs.DetectProjectType(os.DirFS(workingDir)) + if err != nil { + return fmt.Errorf("unable to determine agent language: %w, please navigate to a directory containing an agent written in a supported language", err) + } + fmt.Printf("Detected agent language [%s]\n", util.Accented(string(projectType))) + + if err := requireDockerfile(ctx, cmd, workingDir, projectType, settingsMap); err != nil { + return err + } + + if err := agentfs.CheckSDKVersion(workingDir, projectType, settingsMap); err != nil { + if cmd.Bool("skip-sdk-check") { + fmt.Printf("Error checking SDK version: %v, skipping...\n", err) + } else { + return err + } + } + excludeFiles := []string{fmt.Sprintf("**/%s", config.LiveKitTOMLFile)} resp, err := agentsClient.CreateAgent(buildContext, os.DirFS(workingDir), secrets, regions, excludeFiles, os.Stderr) if err != nil { @@ -725,22 +762,25 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { } func deployAgent(ctx context.Context, cmd *cli.Command) error { - // If no agent exists yet (no --id and no config with agent), do first-time create (which deploys). - if cmd.String("id") == "" { - configExists, err := requireConfig(workingDir, tomlFilename) - if err != nil && configExists { - return err - } - if !configExists || lkConfig == nil || !lkConfig.HasAgent() { - return createAgent(ctx, cmd) - } - } - agentId, err := getAgentID(ctx, cmd, workingDir, tomlFilename, false) if err != nil { return err } + buildContext, cancel := context.WithTimeout(ctx, buildTimeout) + defer cancel() + + // --image or --image-tar: skip source build and push a prebuilt image via the OCI proxy. + imageRef := cmd.String("image") + imageTar := cmd.String("image-tar") + if imageRef != "" || imageTar != "" { + if err := deployPrebuiltImage(buildContext, agentId, imageRef, imageTar); err != nil { + return fmt.Errorf("unable to deploy prebuilt image: %w", err) + } + fmt.Println("Deployed agent") + return nil + } + secrets, err := requireSecrets(ctx, cmd, false, true) if err != nil { return err @@ -765,8 +805,6 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { } } - buildContext, cancel := context.WithTimeout(ctx, buildTimeout) - defer cancel() excludeFiles := []string{fmt.Sprintf("**/%s", config.LiveKitTOMLFile)} if err := agentsClient.DeployAgent(buildContext, agentId, os.DirFS(workingDir), secrets, excludeFiles, os.Stderr); err != nil { if twerr, ok := err.(twirp.Error); ok { @@ -779,6 +817,45 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { return nil } +// deployPrebuiltImage pushes a locally-built image through the cloud-agents OCI proxy. +// Exactly one of imageRef (Docker daemon via the Docker API) or imageTar must be non-empty. +func deployPrebuiltImage(ctx context.Context, agentID, imageRef, imageTar string) error { + target, err := agentsClient.GetPushTarget(ctx, agentID) + if err != nil { + return fmt.Errorf("failed to get push target: %w", err) + } + + var img v1.Image + if imageRef != "" { + imageRef = strings.TrimSpace(imageRef) + fmt.Printf("Loading image from Docker daemon [%s]\n", util.Accented(imageRef)) + var dockerCloser io.Closer + img, dockerCloser, err = agentfs.LoadDockerDaemonImage(ctx, imageRef) + if err != nil { + return err + } + defer dockerCloser.Close() + } else { + fmt.Printf("Loading image from [%s]\n", util.Accented(imageTar)) + img, err = crane.Load(imageTar) + if err != nil { + return fmt.Errorf("failed to load image: %w", err) + } + } + + proxyRef := fmt.Sprintf("%s/%s:%s", target.ProxyHost, target.Name, target.Tag) + fmt.Printf("Pushing image [%s]\n", util.Accented(proxyRef)) + + rt := agentsClient.NewRegistryTransport() + if err := crane.Push(img, proxyRef, + crane.WithTransport(rt), + crane.WithAuth(authn.Anonymous), + ); err != nil { + return fmt.Errorf("failed to push image: %w", err) + } + return nil +} + func getAgentStatus(ctx context.Context, cmd *cli.Command) error { agentID, err := getAgentID(ctx, cmd, workingDir, tomlFilename, false) if err != nil { @@ -1028,21 +1105,37 @@ func listAgentVersions(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("unable to list agent versions: %w", err) } - table := util.CreateTable(). - Headers("Version", "Current", "Status", "Created At", "Deployed At") - // Sort versions by created date descending slices.SortFunc(versions.Versions, func(a, b *lkproto.AgentVersion) int { return b.CreatedAt.AsTime().Compare(a.CreatedAt.AsTime()) }) + + showDigest := false + for _, v := range versions.Versions { + if v.Attributes["image_digest"] != "" { + showDigest = true + break + } + } + + headers := []string{"Version", "Current", "Status", "Created At", "Deployed At"} + if showDigest { + headers = append(headers, "Digest") + } + table := util.CreateTable().Headers(headers...) + for _, version := range versions.Versions { - table.Row( + row := []string{ version.Version, fmt.Sprintf("%t", version.Current), version.Status, version.CreatedAt.AsTime().Format(time.RFC3339), version.DeployedAt.AsTime().Format(time.RFC3339), - ) + } + if showDigest { + row = append(row, version.Attributes["image_digest"]) + } + table.Row(row...) } fmt.Println(table) diff --git a/go.mod b/go.mod index c2eaa927..116e936e 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,11 @@ require ( github.com/charmbracelet/huh v0.7.1-0.20250818142555-c41a69ba6443 github.com/charmbracelet/huh/spinner v0.0.0-20250818142555-c41a69ba6443 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/docker/docker v28.2.2+incompatible github.com/frostbyte73/core v0.1.1 github.com/go-logr/logr v1.4.3 github.com/go-task/task/v3 v3.44.1 + github.com/google/go-containerregistry v0.20.6 github.com/joho/godotenv v1.5.1 github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3 github.com/livekit/server-sdk-go/v2 v2.16.1 @@ -69,6 +71,7 @@ require ( github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v1.0.0-rc.2 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.17.0 // indirect github.com/containerd/ttrpc v1.2.7 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/cyphar/filepath-securejoin v0.6.0 // indirect @@ -77,6 +80,11 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/cli v29.0.0+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dominikbraun/graph v0.23.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect @@ -122,8 +130,10 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/buildkit v0.26.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/sys/signal v0.7.1 // indirect github.com/morikuni/aec v1.0.0 // indirect @@ -172,6 +182,7 @@ require ( github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect + github.com/vbatts/tar-split v0.12.2 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect diff --git a/go.sum b/go.sum index ead5d6b7..250dbda2 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ= @@ -129,7 +131,6 @@ github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6a github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= github.com/containerd/plugin v1.0.0 h1:c8Kf1TNl6+e2TtMHZt+39yAPDbouRH9WAToRjex483Y= github.com/containerd/plugin v1.0.0/go.mod h1:hQfJe5nmWfImiqT1q8Si3jLv3ynMUIBB47bQ+KexvO8= -github.com/containerd/stargz-snapshotter v0.17.0 h1:djNS4KU8ztFhLdEDZ1bsfzOiYuVHT6TgSU5qwRk+cNc= github.com/containerd/stargz-snapshotter/estargz v0.17.0 h1:+TyQIsR/zSFI1Rm31EQBwpAA1ovYgIKHy7kctL3sLcE= github.com/containerd/stargz-snapshotter/estargz v0.17.0/go.mod h1:s06tWAiJcXQo9/8AReBCIo/QxcXFZ2n4qfsRnpl71SM= github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ= @@ -154,6 +155,10 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.0.0+incompatible h1:KgsN2RUFMNM8wChxryicn4p46BdQWpXOA1XLGBGPGAw= github.com/docker/cli v29.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= +github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -226,6 +231,8 @@ github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXn github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= +github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -291,6 +298,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/moby/buildkit v0.26.2 h1:EIh5j0gzRsCZmQzvgNNWzSDbuKqwUIiBH7ssqLv8RU8= @@ -305,6 +314,8 @@ github.com/moby/moby/client v0.1.0 h1:nt+hn6O9cyJQqq5UWnFGqsZRTS/JirUqzPjEl0Bdc/ github.com/moby/moby/client v0.1.0/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= @@ -500,6 +511,8 @@ go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= @@ -632,6 +645,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI= diff --git a/pkg/agentfs/docker_daemon_image.go b/pkg/agentfs/docker_daemon_image.go new file mode 100644 index 00000000..b365f3d7 --- /dev/null +++ b/pkg/agentfs/docker_daemon_image.go @@ -0,0 +1,244 @@ +// 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 agentfs + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/daemon" +) + +// LoadDockerDaemonImage loads a v1.Image from the local Docker daemon by tag or equivalent +// RepoTag. The caller must Close() the returned closer after finishing with the image +// (e.g. after pushing layers). +func LoadDockerDaemonImage(ctx context.Context, ref string) (v1.Image, io.Closer, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, nil, errors.New("empty image reference") + } + if _, err := name.ParseReference(ref); err != nil { + return nil, nil, fmt.Errorf("invalid image reference: %w", err) + } + + cli, id, err := dockerClientForImage(ctx, ref) + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve image in Docker daemon: %w", err) + } + + img, err := daemon.Image(dockerImageIDRef(id), daemon.WithContext(ctx), daemon.WithClient(cli)) + if err != nil { + cli.Close() + return nil, nil, fmt.Errorf("failed to load image from Docker daemon: %w", err) + } + return img, cli, nil +} + +// dockerImageIDRef implements name.Reference for a Docker Engine image ID (e.g. "sha256:..."). +// pkg/name cannot parse that form; daemon.Image only needs Name/String for the API. +type dockerImageIDRef string + +func (r dockerImageIDRef) Context() name.Repository { return name.Repository{} } + +func (r dockerImageIDRef) Identifier() string { + s := string(r) + if i := strings.LastIndex(s, ":"); i >= 0 { + return s[i+1:] + } + return s +} + +func (r dockerImageIDRef) Name() string { return string(r) } + +func (r dockerImageIDRef) String() string { return string(r) } + +func (r dockerImageIDRef) Scope(string) string { return "" } + +// dockerImageRefKeySet returns normalized spellings of an image reference so two sets +// intersect iff the daemon would treat them as the same tag (e.g. my-app:latest vs +// docker.io/library/my-app:latest). +func dockerImageRefKeySet(ref string) map[string]struct{} { + m := map[string]struct{}{} + var add func(string) + add = func(s string) { + s = strings.TrimSpace(s) + if s == "" || s == ":" { + return + } + if _, ok := m[s]; ok { + return + } + m[s] = struct{}{} + + if rest, ok := strings.CutPrefix(s, "docker.io/"); ok { + add(rest) + } + if rest, ok := strings.CutPrefix(s, "index.docker.io/"); ok { + add(rest) + } + if rest, ok := strings.CutPrefix(s, "registry-1.docker.io/"); ok { + add(rest) + } + if rest, ok := strings.CutPrefix(s, "library/"); ok { + add(rest) + } + + if !strings.Contains(s, "@") && strings.Count(s, ":") == 1 { + i := strings.Index(s, ":") + repo, tag := s[:i], s[i+1:] + if repo != "" && tag != "" && !strings.Contains(repo, "/") { + add("library/" + repo + ":" + tag) + add("docker.io/library/" + repo + ":" + tag) + add("docker.io/" + repo + ":" + tag) + } + } + } + add(ref) + return m +} + +func dockerImageRefsMatch(a, b string) bool { + if a == b { + return true + } + ka, kb := dockerImageRefKeySet(a), dockerImageRefKeySet(b) + for k := range ka { + if _, ok := kb[k]; ok { + return true + } + } + return false +} + +func resolveLocalDockerImageID(ctx context.Context, c *client.Client, ref string) (string, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return "", errors.New("empty image reference") + } + + if insp, err := c.ImageInspect(ctx, ref); err == nil && insp.ID != "" { + return insp.ID, nil + } + + for _, candidate := range dockerImageRefKeyList(ref) { + f := filters.NewArgs(filters.Arg("reference", candidate)) + imgs, err := c.ImageList(ctx, image.ListOptions{Filters: f}) + if err != nil { + continue + } + for _, im := range imgs { + if im.ID != "" { + return im.ID, nil + } + } + } + + imgs, err := c.ImageList(ctx, image.ListOptions{}) + if err != nil { + return "", fmt.Errorf("docker image list: %w", err) + } + + for _, im := range imgs { + for _, tag := range im.RepoTags { + if dockerImageRefsMatch(ref, tag) { + return im.ID, nil + } + } + } + return "", fmt.Errorf("no local Docker image matches %q", ref) +} + +func dockerImageRefKeyList(ref string) []string { + var out []string + seen := map[string]struct{}{} + for k := range dockerImageRefKeySet(ref) { + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, k) + } + return out +} + +// dockerClientForImage tries successive API endpoints so we match the Docker CLI: default +// is FromEnv; when DOCKER_HOST is unset, Docker Desktop often uses ~/.docker/run/docker.sock +// while the Go client defaults to /var/run/docker.sock (they may not be the same node). +func dockerClientForImage(ctx context.Context, ref string) (*client.Client, string, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, "", errors.New("empty image reference") + } + + try := func(opts ...client.Opt) (*client.Client, error) { + return client.NewClientWithOpts(opts...) + } + + var hostOrder []string + if os.Getenv("DOCKER_HOST") != "" { + hostOrder = append(hostOrder, "") // FromEnv only + } else { + hostOrder = append(hostOrder, "") + if home, err := os.UserHomeDir(); err == nil { + sock := filepath.Join(home, ".docker", "run", "docker.sock") + if fi, err := os.Stat(sock); err == nil && !fi.IsDir() { + u := "unix://" + filepath.ToSlash(sock) + hostOrder = append(hostOrder, u) + } + } + } + + var lastErr error + seenHost := map[string]struct{}{} + for _, explicitHost := range hostOrder { + if _, dup := seenHost[explicitHost]; dup { + continue + } + seenHost[explicitHost] = struct{}{} + + var cli *client.Client + var err error + if explicitHost == "" { + cli, err = try(client.FromEnv, client.WithAPIVersionNegotiation()) + } else { + cli, err = try(client.WithHost(explicitHost), client.WithAPIVersionNegotiation()) + } + if err != nil { + lastErr = err + continue + } + + id, err := resolveLocalDockerImageID(ctx, cli, ref) + if err == nil { + return cli, id, nil + } + lastErr = err + cli.Close() + } + if lastErr == nil { + lastErr = errors.New("could not connect to Docker") + } + return nil, "", lastErr +} diff --git a/pkg/agentfs/docker_daemon_image_test.go b/pkg/agentfs/docker_daemon_image_test.go new file mode 100644 index 00000000..aeba1f5e --- /dev/null +++ b/pkg/agentfs/docker_daemon_image_test.go @@ -0,0 +1,154 @@ +// 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 agentfs + +import ( + "context" + "sort" + "strings" + "testing" +) + +func TestDockerImageRefsMatch(t *testing.T) { + t.Parallel() + tests := []struct { + name string + a, b string + want bool + }{ + { + name: "bare tag vs docker hub library form", + a: "my-agent:latest", + b: "docker.io/library/my-agent:latest", + want: true, + }, + { + name: "bare tag vs index alias", + a: "my-agent:latest", + b: "index.docker.io/library/my-agent:latest", + want: true, + }, + { + name: "bare tag vs registry-1 alias", + a: "my-agent:latest", + b: "registry-1.docker.io/library/my-agent:latest", + want: true, + }, + { + name: "same string", + a: "docker.io/library/foo:v1", + b: "docker.io/library/foo:v1", + want: true, + }, + { + name: "different repos", + a: "my-agent:latest", + b: "other-agent:latest", + want: false, + }, + { + name: "different tags", + a: "my-agent:latest", + b: "my-agent:old", + want: false, + }, + { + name: "different namespaced repos", + a: "acme/service-a:v1", + b: "acme/service-b:v1", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := dockerImageRefsMatch(tt.a, tt.b); got != tt.want { + t.Fatalf("dockerImageRefsMatch(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestDockerImageRefKeySet_containsExpectedVariants(t *testing.T) { + t.Parallel() + ref := "my-agent:latest" + keys := dockerImageRefKeySet(ref) + for _, want := range []string{ + "my-agent:latest", + "library/my-agent:latest", + "docker.io/library/my-agent:latest", + "docker.io/my-agent:latest", + } { + if _, ok := keys[want]; !ok { + t.Errorf("expected key set to contain %q, keys were: %v", want, sortedKeys(keys)) + } + } +} + +func TestDockerImageRefKeySet_ignoresNoneTag(t *testing.T) { + t.Parallel() + keys := dockerImageRefKeySet(":") + if len(keys) != 0 { + t.Fatalf("expected empty set, got %v", sortedKeys(keys)) + } +} + +func TestDockerImageIDRef_nameReference(t *testing.T) { + t.Parallel() + const id = "sha256:eb540705f833d454ccb727f23dde5a9465af831e4aad4b76e917d620a9a58624" + r := dockerImageIDRef(id) + if r.Name() != id || r.String() != id { + t.Fatalf("Name/String = %q / %q, want %q", r.Name(), r.String(), id) + } + if got := r.Identifier(); got != "eb540705f833d454ccb727f23dde5a9465af831e4aad4b76e917d620a9a58624" { + t.Fatalf("Identifier() = %q", got) + } +} + +func TestLoadDockerDaemonImage_validation(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("empty ref", func(t *testing.T) { + t.Parallel() + _, closer, err := LoadDockerDaemonImage(ctx, "") + if closer != nil { + t.Fatal("expected nil closer") + } + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("expected empty ref error, got %v", err) + } + }) + + t.Run("invalid ref", func(t *testing.T) { + t.Parallel() + _, closer, err := LoadDockerDaemonImage(ctx, "not a valid ::: reference") + if closer != nil { + t.Fatal("expected nil closer") + } + if err == nil || !strings.Contains(err.Error(), "invalid image reference") { + t.Fatalf("expected invalid reference error, got %v", err) + } + }) +} + +func sortedKeys(m map[string]struct{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +}