Skip to content

Commit b9d67a7

Browse files
authored
Add support for private OCI registries (#990)
Signed-off-by: Radoslav Dimitrov <[email protected]>
1 parent d858820 commit b9d67a7

File tree

14 files changed

+275
-54
lines changed

14 files changed

+275
-54
lines changed

cmd/thv/app/run.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/stacklok/toolhive/pkg/config"
1111
"github.com/stacklok/toolhive/pkg/container"
12+
"github.com/stacklok/toolhive/pkg/container/runtime"
1213
"github.com/stacklok/toolhive/pkg/logger"
1314
"github.com/stacklok/toolhive/pkg/permissions"
1415
"github.com/stacklok/toolhive/pkg/process"
@@ -285,7 +286,7 @@ func runCmdFunc(cmd *cobra.Command, args []string) error {
285286
// If we are running in detached mode, or the CLI is wrapped by the K8s operator,
286287
// we use the DetachedEnvVarValidator.
287288
var envVarValidator runner.EnvVarValidator
288-
if process.IsDetached() || container.IsKubernetesRuntime() {
289+
if process.IsDetached() || runtime.IsKubernetesRuntime() {
289290
envVarValidator = &runner.DetachedEnvVarValidator{}
290291
} else {
291292
envVarValidator = &runner.CLIEnvVarValidator{}
@@ -297,7 +298,7 @@ func runCmdFunc(cmd *cobra.Command, args []string) error {
297298
// Only pull image if we are not running in Kubernetes mode.
298299
// This split will go away if we implement a separate command or binary
299300
// for running MCP servers in Kubernetes.
300-
if !container.IsKubernetesRuntime() {
301+
if !runtime.IsKubernetesRuntime() {
301302
// Take the MCP server we were supplied and either fetch the image, or
302303
// build it from a protocol scheme. If the server URI refers to an image
303304
// in our trusted registry, we will also fetch the image metadata.

cmd/thv/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66

77
"github.com/stacklok/toolhive/cmd/thv/app"
88
"github.com/stacklok/toolhive/pkg/client"
9-
"github.com/stacklok/toolhive/pkg/container"
9+
"github.com/stacklok/toolhive/pkg/container/runtime"
1010
"github.com/stacklok/toolhive/pkg/logger"
1111
)
1212

@@ -19,7 +19,7 @@ func main() {
1919
client.CheckAndPerformAutoDiscoveryMigration()
2020

2121
// Skip update check for completion command or if we are running in kubernetes
22-
if err := app.NewRootCmd(!app.IsCompletionCommand(os.Args) && !container.IsKubernetesRuntime()).Execute(); err != nil {
22+
if err := app.NewRootCmd(!app.IsCompletionCommand(os.Args) && !runtime.IsKubernetesRuntime()).Execute(); err != nil {
2323
os.Exit(1)
2424
}
2525
}

pkg/container/docker/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func NewClient(ctx context.Context) (*Client, error) {
5656
return nil, err // there is already enough context in the error.
5757
}
5858

59-
imageManager := images.NewDockerImageManager(dockerClient)
59+
imageManager := images.NewRegistryImageManager(dockerClient)
6060

6161
c := &Client{
6262
runtimeType: runtimeType,

pkg/container/factory.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package container
44

55
import (
66
"context"
7-
"os"
87

98
"github.com/stacklok/toolhive/pkg/container/docker"
109
"github.com/stacklok/toolhive/pkg/container/kubernetes"
@@ -21,7 +20,7 @@ func NewFactory() *Factory {
2120

2221
// Create creates a container runtime
2322
func (*Factory) Create(ctx context.Context) (runtime.Runtime, error) {
24-
if !IsKubernetesRuntime() {
23+
if !runtime.IsKubernetesRuntime() {
2524
client, err := docker.NewClient(ctx)
2625
if err != nil {
2726
return nil, err
@@ -41,9 +40,3 @@ func (*Factory) Create(ctx context.Context) (runtime.Runtime, error) {
4140
func NewMonitor(rt runtime.Runtime, containerID, containerName string) runtime.Monitor {
4241
return docker.NewMonitor(rt, containerID, containerName)
4342
}
44-
45-
// IsKubernetesRuntime returns true if the runtime is Kubernetes
46-
// isn't the best way to do this, but for now it's good enough
47-
func IsKubernetesRuntime() bool {
48-
return os.Getenv("KUBERNETES_SERVICE_HOST") != ""
49-
}

pkg/container/images/docker.go

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,30 @@ func (d *DockerImageManager) ImageExists(ctx context.Context, imageName string)
4949

5050
// BuildImage builds a Docker image from a Dockerfile in the specified context directory
5151
func (d *DockerImageManager) BuildImage(ctx context.Context, contextDir, imageName string) error {
52+
return buildDockerImage(ctx, d.client, contextDir, imageName)
53+
}
54+
55+
// PullImage pulls an image from a registry
56+
func (d *DockerImageManager) PullImage(ctx context.Context, imageName string) error {
57+
logger.Infof("Pulling image: %s", imageName)
58+
59+
// Pull the image
60+
reader, err := d.client.ImagePull(ctx, imageName, dockerimage.PullOptions{})
61+
if err != nil {
62+
return fmt.Errorf("failed to pull image: %v", err)
63+
}
64+
defer reader.Close()
65+
66+
// Parse and filter the pull output
67+
if err := parsePullOutput(reader, os.Stdout); err != nil {
68+
return fmt.Errorf("failed to process pull output: %v", err)
69+
}
70+
71+
return nil
72+
}
73+
74+
// buildDockerImage builds a Docker image using the Docker client API
75+
func buildDockerImage(ctx context.Context, dockerClient *client.Client, contextDir, imageName string) error {
5276
logger.Infof("Building image %s from context directory %s", imageName, contextDir)
5377

5478
// Create a tar archive of the context directory
@@ -76,7 +100,7 @@ func (d *DockerImageManager) BuildImage(ctx context.Context, contextDir, imageNa
76100
Remove: true,
77101
}
78102

79-
response, err := d.client.ImageBuild(ctx, tarFile, buildOptions)
103+
response, err := dockerClient.ImageBuild(ctx, tarFile, buildOptions)
80104
if err != nil {
81105
return fmt.Errorf("failed to build image: %v", err)
82106
}
@@ -90,25 +114,6 @@ func (d *DockerImageManager) BuildImage(ctx context.Context, contextDir, imageNa
90114
return nil
91115
}
92116

93-
// PullImage pulls an image from a registry
94-
func (d *DockerImageManager) PullImage(ctx context.Context, imageName string) error {
95-
logger.Infof("Pulling image: %s", imageName)
96-
97-
// Pull the image
98-
reader, err := d.client.ImagePull(ctx, imageName, dockerimage.PullOptions{})
99-
if err != nil {
100-
return fmt.Errorf("failed to pull image: %v", err)
101-
}
102-
defer reader.Close()
103-
104-
// Parse and filter the pull output
105-
if err := parsePullOutput(reader, os.Stdout); err != nil {
106-
return fmt.Errorf("failed to process pull output: %v", err)
107-
}
108-
109-
return nil
110-
}
111-
112117
// createTarFromDir creates a tar archive from a directory
113118
func createTarFromDir(srcDir string, writer io.Writer) error {
114119
// Create a new tar writer

pkg/container/images/image.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66

77
"github.com/stacklok/toolhive/pkg/container/docker/sdk"
8+
"github.com/stacklok/toolhive/pkg/container/runtime"
89
"github.com/stacklok/toolhive/pkg/logger"
910
)
1011

@@ -26,15 +27,20 @@ type ImageManager interface {
2627
// NewImageManager creates an instance of ImageManager appropriate
2728
// for the current environment, or returns an error if it is not supported.
2829
func NewImageManager(ctx context.Context) ImageManager {
29-
// ASSUMPTION: This only works for the Docker runtime.
30-
// Otherwise, return a no-op implementation.
30+
// Check if we are running in a Kubernetes environment
31+
if runtime.IsKubernetesRuntime() {
32+
logger.Debug("running in Kubernetes environment, using no-op image manager")
33+
return &NoopImageManager{}
34+
}
35+
36+
// Check if we are running in a Docker or compatible environment
3137
dockerClient, _, _, err := sdk.NewDockerClient(ctx)
3238
if err != nil {
3339
logger.Debug("no docker runtime found, using no-op image manager")
3440
return &NoopImageManager{}
3541
}
3642

37-
return NewDockerImageManager(dockerClient)
43+
return NewRegistryImageManager(dockerClient)
3844
}
3945

4046
// NoopImageManager is a no-op implementation of ImageManager.

pkg/container/images/keychain.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package images
2+
3+
import (
4+
"os"
5+
"strings"
6+
7+
"github.com/google/go-containerregistry/pkg/authn"
8+
)
9+
10+
// envKeychain implements a keychain that reads credentials from environment variables
11+
type envKeychain struct{}
12+
13+
// Resolve implements the authn.Keychain interface
14+
func (*envKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) {
15+
registry := target.RegistryStr()
16+
17+
// Try registry-specific environment variables first
18+
// Format: REGISTRY_<NORMALIZED_REGISTRY_NAME>_USERNAME/PASSWORD, i.e., REGISTRY_DOCKER_IO_USERNAME
19+
normalizedRegistry := strings.ToUpper(strings.ReplaceAll(registry, ".", "_"))
20+
normalizedRegistry = strings.ReplaceAll(normalizedRegistry, "-", "_")
21+
22+
username := os.Getenv("REGISTRY_" + normalizedRegistry + "_USERNAME")
23+
password := os.Getenv("REGISTRY_" + normalizedRegistry + "_PASSWORD")
24+
25+
// If registry-specific vars not found, try generic one REGISTRY_USERNAME/PASSWORD
26+
if username == "" || password == "" {
27+
username = os.Getenv("REGISTRY_USERNAME")
28+
password = os.Getenv("REGISTRY_PASSWORD")
29+
}
30+
31+
if username != "" && password != "" {
32+
return &authn.Basic{
33+
Username: username,
34+
Password: password,
35+
}, nil
36+
}
37+
38+
return authn.Anonymous, nil
39+
}
40+
41+
// compositeKeychain combines multiple keychains and tries them in order
42+
type compositeKeychain struct {
43+
keychains []authn.Keychain
44+
}
45+
46+
// Resolve implements the authn.Keychain interface
47+
func (c *compositeKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) {
48+
for _, keychain := range c.keychains {
49+
auth, err := keychain.Resolve(target)
50+
if err != nil {
51+
continue
52+
}
53+
54+
// Check if we got actual credentials (not anonymous)
55+
if auth != authn.Anonymous {
56+
return auth, nil
57+
}
58+
}
59+
60+
// If no keychain provided credentials, return anonymous
61+
return authn.Anonymous, nil
62+
}
63+
64+
// NewCompositeKeychain creates a keychain that tries environment variables first,
65+
// then falls back to the default keychain
66+
func NewCompositeKeychain() authn.Keychain {
67+
return &compositeKeychain{
68+
keychains: []authn.Keychain{
69+
&envKeychain{}, // Try environment variables first
70+
authn.DefaultKeychain, // Then try default keychain (Docker config, etc.)
71+
},
72+
}
73+
}

pkg/container/images/registry.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package images
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"runtime"
8+
9+
"github.com/docker/docker/client"
10+
"github.com/google/go-containerregistry/pkg/authn"
11+
"github.com/google/go-containerregistry/pkg/name"
12+
"github.com/google/go-containerregistry/pkg/v1"
13+
"github.com/google/go-containerregistry/pkg/v1/daemon"
14+
"github.com/google/go-containerregistry/pkg/v1/remote"
15+
16+
"github.com/stacklok/toolhive/pkg/logger"
17+
)
18+
19+
// RegistryImageManager implements the ImageManager interface using go-containerregistry
20+
// for direct registry operations without requiring a Docker daemon.
21+
// However, for building images from Dockerfiles, it still uses the Docker client.
22+
type RegistryImageManager struct {
23+
keychain authn.Keychain
24+
platform *v1.Platform
25+
dockerClient *client.Client
26+
}
27+
28+
// NewRegistryImageManager creates a new RegistryImageManager instance
29+
func NewRegistryImageManager(dockerClient *client.Client) *RegistryImageManager {
30+
return &RegistryImageManager{
31+
keychain: NewCompositeKeychain(), // Use composite keychain (env vars + default)
32+
platform: getDefaultPlatform(), // Use a default platform based on host architecture
33+
dockerClient: dockerClient, // Used solely for building images from Dockerfiles
34+
}
35+
}
36+
37+
// getDefaultPlatform returns the default platform for containers
38+
// Uses host architecture
39+
func getDefaultPlatform() *v1.Platform {
40+
return &v1.Platform{
41+
Architecture: runtime.GOARCH,
42+
OS: "linux", // TODO: Should we support Windows too?
43+
}
44+
}
45+
46+
// ImageExists checks if an image exists locally in the daemon or remotely in the registry
47+
func (*RegistryImageManager) ImageExists(_ context.Context, imageName string) (bool, error) {
48+
// Parse the image reference
49+
ref, err := name.ParseReference(imageName)
50+
if err != nil {
51+
return false, fmt.Errorf("failed to parse image reference %q: %w", imageName, err)
52+
}
53+
54+
// First check if image exists locally in daemon
55+
if _, err := daemon.Image(ref); err != nil {
56+
// Image does not exist locally
57+
return false, nil
58+
}
59+
// Image exists locally
60+
return true, nil
61+
}
62+
63+
// PullImage pulls an image from a registry and saves it to the local daemon
64+
func (r *RegistryImageManager) PullImage(ctx context.Context, imageName string) error {
65+
logger.Infof("Pulling image: %s", imageName)
66+
67+
// Parse the image reference
68+
ref, err := name.ParseReference(imageName)
69+
if err != nil {
70+
return fmt.Errorf("failed to parse image reference %q: %w", imageName, err)
71+
}
72+
73+
// Configure remote options
74+
remoteOpts := []remote.Option{
75+
remote.WithAuthFromKeychain(r.keychain),
76+
remote.WithContext(ctx),
77+
}
78+
79+
if r.platform != nil {
80+
remoteOpts = append(remoteOpts, remote.WithPlatform(*r.platform))
81+
}
82+
83+
// Pull the image from the registry
84+
img, err := remote.Image(ref, remoteOpts...)
85+
if err != nil {
86+
return fmt.Errorf("failed to pull image from registry: %w", err)
87+
}
88+
89+
// Convert reference to tag for daemon.Write
90+
tag, ok := ref.(name.Tag)
91+
if !ok {
92+
// If it's not a tag, try to convert to tag
93+
tag, err = name.NewTag(ref.String())
94+
if err != nil {
95+
return fmt.Errorf("failed to convert reference to tag: %w", err)
96+
}
97+
}
98+
99+
// Save the image to the local daemon
100+
response, err := daemon.Write(tag, img)
101+
if err != nil {
102+
return fmt.Errorf("failed to write image to daemon: %w", err)
103+
}
104+
105+
// Display success message
106+
fmt.Fprintf(os.Stdout, "Successfully pulled %s\n", imageName)
107+
logger.Infof("Pull complete for image: %s, response: %s", imageName, response)
108+
109+
return nil
110+
}
111+
112+
// BuildImage builds a Docker image from a Dockerfile in the specified context directory
113+
func (r *RegistryImageManager) BuildImage(ctx context.Context, contextDir, imageName string) error {
114+
return buildDockerImage(ctx, r.dockerClient, contextDir, imageName)
115+
}
116+
117+
// WithKeychain sets the keychain for authentication
118+
func (r *RegistryImageManager) WithKeychain(keychain authn.Keychain) *RegistryImageManager {
119+
r.keychain = keychain
120+
return r
121+
}
122+
123+
// WithPlatform sets the platform for the RegistryImageManager
124+
func (r *RegistryImageManager) WithPlatform(platform *v1.Platform) *RegistryImageManager {
125+
r.platform = platform
126+
return r
127+
}

pkg/container/runtime/types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package runtime
55
import (
66
"context"
77
"io"
8+
"os"
89
"time"
910

1011
"github.com/stacklok/toolhive/pkg/permissions"
@@ -219,3 +220,9 @@ type Mount struct {
219220
// ReadOnly indicates if the mount is read-only
220221
ReadOnly bool
221222
}
223+
224+
// IsKubernetesRuntime returns true if the runtime is Kubernetes
225+
// isn't the best way to do this, but for now it's good enough
226+
func IsKubernetesRuntime() bool {
227+
return os.Getenv("KUBERNETES_SERVICE_HOST") != ""
228+
}

0 commit comments

Comments
 (0)