diff --git a/.github/actions/setup-bink/action.yml b/.github/actions/setup-bink/action.yml index 0049f9b..b1dcc41 100644 --- a/.github/actions/setup-bink/action.yml +++ b/.github/actions/setup-bink/action.yml @@ -80,20 +80,6 @@ runs: shell: bash run: make build-bink - - name: Build cluster and DNS images - shell: bash - run: | - make build-cluster-image - make build-dns-image - - - name: Verify prerequisites - shell: bash - run: | - test -f ./bink - podman images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" - df -h / - free -h - - name: Get image digests id: digests shell: bash @@ -139,3 +125,19 @@ runs: with: path: /tmp/podman-image-cache key: podman-images-v2-${{ inputs.cache-key-prefix }}-${{ steps.digests.outputs.hash }} + + # Build checkout-owned images after loading the cache so published images + # cannot replace changes made by the pull request under the same tags. + - name: Build cluster and DNS images + shell: bash + run: | + make build-cluster-image + make build-dns-image + + - name: Verify prerequisites + shell: bash + run: | + test -f ./bink + podman images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" + df -h / + free -h diff --git a/containerfiles/dns/cluster-hosts b/containerfiles/dns/cluster-hosts index c3bc0a1..9ba7edc 100644 --- a/containerfiles/dns/cluster-hosts +++ b/containerfiles/dns/cluster-hosts @@ -1 +1,2 @@ 10.88.0.2 registry registry.cluster.local +10.88.0.3 auth-registry auth-registry.cluster.local diff --git a/go.mod b/go.mod index 079a2c7..6efa880 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/spf13/viper v1.21.0 go.podman.io/common v0.69.1 go.podman.io/podman/v6 v6.1.1 + golang.org/x/crypto v0.54.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.37.0 k8s.io/apimachinery v0.37.0 @@ -168,7 +169,6 @@ require ( go.podman.io/storage v1.64.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.54.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/internal/cli/cluster/start.go b/internal/cli/cluster/start.go index 353b696..75af550 100644 --- a/internal/cli/cluster/start.go +++ b/internal/cli/cluster/start.go @@ -30,6 +30,8 @@ func newStartCmd() *cobra.Command { var exposePath string var hostNetworkPopulator bool var targetImgRef string + var registryUser string + var registryPassword string cmd := &cobra.Command{ Use: "start", @@ -45,7 +47,7 @@ func newStartCmd() *cobra.Command { bink cluster start --memory 4096 --expose ./kubeconfig`, RunE: func(cmd *cobra.Command, args []string) error { logger := logrus.New() - return runStart(cmd.Context(), logger, nodeName, nodeImage, apiPort, memory, maxMemory, exposePath, hostNetworkPopulator, targetImgRef) + return runStart(cmd.Context(), logger, nodeName, nodeImage, apiPort, memory, maxMemory, exposePath, hostNetworkPopulator, targetImgRef, registryUser, registryPassword) }, } @@ -57,11 +59,18 @@ func newStartCmd() *cobra.Command { cmd.Flags().StringVar(&exposePath, "expose", "", "Expose API and save kubeconfig to PATH after cluster is up") cmd.Flags().BoolVar(&hostNetworkPopulator, "host-network-populator", false, "Use host networking for the image populator container (fixes DNS in nested podman)") cmd.Flags().StringVar(&targetImgRef, "target-imgref", "", "Override the bootc image reference tracked by the VM (e.g., registry.cluster.local:5000/node:latest)") + cmd.Flags().StringVar(®istryUser, "registry-user", "", "Username for the authenticated registry") + cmd.Flags().StringVar(®istryPassword, "registry-password", "", "Password for the authenticated registry") return cmd } -func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeImage string, apiPort int, memory int, maxMemory int, exposePath string, hostNetworkPopulator bool, targetImgRef string) error { +func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeImage string, apiPort int, memory int, maxMemory int, exposePath string, hostNetworkPopulator bool, targetImgRef string, registryUser string, registryPassword string) error { + authRegistryRequested, err := registry.AuthRegistryRequested(registryUser, registryPassword) + if err != nil { + return fmt.Errorf("invalid auth registry credentials: %w", err) + } + logger.Info("=== Creating Kubernetes cluster ===") logger.Info("") @@ -85,6 +94,11 @@ func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeI if err := registryMgr.EnsureRegistry(ctx); err != nil { return fmt.Errorf("ensuring registry: %w", err) } + if authRegistryRequested { + if err := registryMgr.EnsureAuthRegistry(ctx, registryUser, registryPassword); err != nil { + return fmt.Errorf("ensuring auth registry: %w", err) + } + } logger.Info("") logger.Info("Step 3: Ensuring DNS container...") @@ -197,6 +211,11 @@ func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeI logger.Infof(" Push: podman push --tls-verify=false localhost:%d/:", config.RegistryPort) logger.Infof(" Pull (in-cluster): %s.%s:%d/:", config.RegistryHostname, config.ClusterDomain, config.RegistryPort) logger.Info("") + if authRegistryRequested { + logger.Info("Auth registry (pull with credentials):") + logger.Infof(" Pull (in-cluster): %s.%s:%d/:", config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort) + logger.Info("") + } if exposePath != "" { logger.Info("Step 9: Exposing API server...") diff --git a/internal/cli/cluster/start_test.go b/internal/cli/cluster/start_test.go new file mode 100644 index 0000000..3b365d8 --- /dev/null +++ b/internal/cli/cluster/start_test.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 The bink Authors +// SPDX-License-Identifier: Apache-2.0 + +package cluster + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestStartCredentialFlagsDefaultToEmpty(t *testing.T) { + g := NewWithT(t) + cmd := newStartCmd() + + username, err := cmd.Flags().GetString("registry-user") + g.Expect(err).ToNot(HaveOccurred()) + password, err := cmd.Flags().GetString("registry-password") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(username).To(BeEmpty()) + g.Expect(password).To(BeEmpty()) +} diff --git a/internal/cli/cluster/stop.go b/internal/cli/cluster/stop.go index a8d878a..2c97e4a 100644 --- a/internal/cli/cluster/stop.go +++ b/internal/cli/cluster/stop.go @@ -118,7 +118,7 @@ func runStop(ctx context.Context, logger *logrus.Logger, force, removeData bool) } else { logger.Info("✅ All cluster data removed") } - logger.Info("Note: Shared registry (bink-registry) is preserved. Use 'bink registry stop' to remove it.") + logger.Info("Note: Shared registries (bink-registry, bink-auth-registry) are preserved. Use 'bink registry stop' to remove them.") } return nil diff --git a/internal/cli/registry/info.go b/internal/cli/registry/info.go index e6e234c..13efdf0 100644 --- a/internal/cli/registry/info.go +++ b/internal/cli/registry/info.go @@ -31,11 +31,27 @@ func newInfoCmd() *cobra.Command { status = define.ContainerStateRunning.String() } - fmt.Printf("Registry: %s\n", status) - fmt.Printf("IP: %s\n", info.IP) - fmt.Printf("Host port: %d\n", info.HostPort) - fmt.Printf("Push: podman push --tls-verify=false %s/:\n", info.PushURL) - fmt.Printf("Pull: %s/:\n", info.PullURL) + fmt.Printf("Registry (unauthenticated): %s\n", status) + fmt.Printf(" IP: %s\n", info.IP) + fmt.Printf(" Host port: %d\n", info.HostPort) + fmt.Printf(" Push: podman push --tls-verify=false %s/:\n", info.PushURL) + fmt.Printf(" Pull: %s/:\n", info.PullURL) + fmt.Println() + + authInfo, err := mgr.AuthRegistryInfo(cmd.Context()) + if err != nil { + return fmt.Errorf("getting auth registry info: %w", err) + } + + authStatus := "stopped" + if authInfo.Running { + authStatus = define.ContainerStateRunning.String() + } + + fmt.Printf("Registry (authenticated): %s\n", authStatus) + fmt.Printf(" IP: %s\n", authInfo.IP) + fmt.Printf(" Host port: %d\n", authInfo.HostPort) + fmt.Printf(" Pull: %s/:\n", authInfo.PullURL) return nil }, diff --git a/internal/cli/registry/start.go b/internal/cli/registry/start.go index de392de..5653fcd 100644 --- a/internal/cli/registry/start.go +++ b/internal/cli/registry/start.go @@ -11,23 +11,47 @@ import ( ) func newStartCmd() *cobra.Command { + var authOnly bool + var registryUser string + var registryPassword string + cmd := &cobra.Command{ Use: "start", Short: "Start the local container registry", - Long: "Start the shared local registry container, creating it if it doesn't exist", + Long: "Start the shared local registry containers, creating them if they don't exist", RunE: func(cmd *cobra.Command, args []string) error { + authRequested, err := registrypkg.AuthRegistryRequested(registryUser, registryPassword) + if err != nil { + return fmt.Errorf("invalid auth registry credentials: %w", err) + } + if authOnly && !authRequested { + return fmt.Errorf("invalid auth registry credentials: registry username and password are required with --auth") + } + mgr, err := registrypkg.NewManager() if err != nil { return fmt.Errorf("creating registry manager: %w", err) } - if err := mgr.EnsureRegistry(cmd.Context()); err != nil { - return fmt.Errorf("starting registry: %w", err) + if !authOnly { + if err := mgr.EnsureRegistry(cmd.Context()); err != nil { + return fmt.Errorf("starting registry: %w", err) + } + } + + if authRequested { + if err := mgr.EnsureAuthRegistry(cmd.Context(), registryUser, registryPassword); err != nil { + return fmt.Errorf("starting auth registry: %w", err) + } } return nil }, } + cmd.Flags().BoolVar(&authOnly, "auth", false, "Start only the authenticated registry") + cmd.Flags().StringVar(®istryUser, "registry-user", "", "Username for the authenticated registry") + cmd.Flags().StringVar(®istryPassword, "registry-password", "", "Password for the authenticated registry") + return cmd } diff --git a/internal/cli/registry/start_test.go b/internal/cli/registry/start_test.go new file mode 100644 index 0000000..02b9524 --- /dev/null +++ b/internal/cli/registry/start_test.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 The bink Authors +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestStartAuthFlagsRequireCredentials(t *testing.T) { + g := NewWithT(t) + cmd := newStartCmd() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--auth"}) + + g.Expect(cmd.Execute()).To(MatchError("invalid auth registry credentials: registry username and password are required with --auth")) +} + +func TestStartCredentialFlagsDefaultToEmpty(t *testing.T) { + g := NewWithT(t) + cmd := newStartCmd() + + username, err := cmd.Flags().GetString("registry-user") + g.Expect(err).ToNot(HaveOccurred()) + password, err := cmd.Flags().GetString("registry-password") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(username).To(BeEmpty()) + g.Expect(password).To(BeEmpty()) +} diff --git a/internal/cli/registry/stop.go b/internal/cli/registry/stop.go index d5026d7..d416c9f 100644 --- a/internal/cli/registry/stop.go +++ b/internal/cli/registry/stop.go @@ -4,6 +4,7 @@ package registry import ( + "errors" "fmt" registrypkg "github.com/bootc-dev/bink/internal/registry" @@ -12,24 +13,38 @@ import ( ) func newStopCmd() *cobra.Command { + var authOnly bool + cmd := &cobra.Command{ Use: "stop", - Short: "Stop and remove the local registry", - Long: "Stop the shared local registry container and remove its data volume", + Short: "Stop and remove the local registries", + Long: "Stop both local registry containers and remove the shared data volume. Use --auth to stop only the authenticated registry.", RunE: func(cmd *cobra.Command, args []string) error { mgr, err := registrypkg.NewManager() if err != nil { return fmt.Errorf("creating registry manager: %w", err) } - if err := mgr.StopRegistry(cmd.Context()); err != nil { - return fmt.Errorf("stopping registry: %w", err) + authErr := mgr.StopAuthRegistry(cmd.Context()) + if authOnly { + if authErr != nil { + return fmt.Errorf("stopping auth registry: %w", authErr) + } + logrus.Info("Auth registry stopped and removed") + return nil + } + + registryErr := mgr.StopRegistry(cmd.Context()) + if err := errors.Join(authErr, registryErr); err != nil { + return fmt.Errorf("stopping registries: %w", err) } - logrus.Info("Registry stopped and data removed") + logrus.Info("All registries stopped and data removed") return nil }, } + cmd.Flags().BoolVar(&authOnly, "auth", false, "Stop only the authenticated registry") + return cmd } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 829e504..c933645 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -76,6 +76,12 @@ const ( RegistryStaticIP = "10.88.0.2" RegistryHostname = "registry" RegistryVolume = "bink-registry-data" + RegistryHTTPSecret = "bink-shared-secret" + + AuthRegistryContainerName = "bink-auth-registry" + AuthRegistryPort = 5001 + AuthRegistryStaticIP = "10.88.0.3" + AuthRegistryHostname = "auth-registry" HAProxyImage = "docker.io/library/haproxy:lts-alpine" HAProxyContainerName = "haproxy" diff --git a/internal/node/cloudinit.go b/internal/node/cloudinit.go index 55dac2a..abbe620 100644 --- a/internal/node/cloudinit.go +++ b/internal/node/cloudinit.go @@ -39,6 +39,10 @@ type CloudInitData struct { RegistryHostname string ServiceCIDR string TargetImgRef string + + AuthRegistryStaticIP string + AuthRegistryPort int + AuthRegistryHostname string } func (n *Node) newCloudInitData(sshPubKey string) CloudInitData { @@ -56,6 +60,10 @@ func (n *Node) newCloudInitData(sshPubKey string) CloudInitData { RegistryHostname: config.RegistryHostname, ServiceCIDR: config.ServiceCIDR, TargetImgRef: n.TargetImgRef, + + AuthRegistryStaticIP: config.AuthRegistryStaticIP, + AuthRegistryPort: config.AuthRegistryPort, + AuthRegistryHostname: config.AuthRegistryHostname, } } diff --git a/internal/node/cloudinit_test.go b/internal/node/cloudinit_test.go index c856045..94e8ba3 100644 --- a/internal/node/cloudinit_test.go +++ b/internal/node/cloudinit_test.go @@ -4,6 +4,7 @@ package node import ( + "fmt" "strings" "testing" @@ -25,6 +26,10 @@ func testCloudInitData() CloudInitData { RegistryPort: config.RegistryPort, RegistryHostname: config.RegistryHostname, ServiceCIDR: config.ServiceCIDR, + + AuthRegistryStaticIP: config.AuthRegistryStaticIP, + AuthRegistryPort: config.AuthRegistryPort, + AuthRegistryHostname: config.AuthRegistryHostname, } } @@ -117,6 +122,15 @@ func TestRenderTemplate_UserData(t *testing.T) { if !strings.Contains(s, registryURL) { t.Errorf("missing registry URL %s", registryURL) } + + authRegistryURL := fmt.Sprintf("%s:%d", config.AuthRegistryStaticIP, config.AuthRegistryPort) + if !strings.Contains(s, authRegistryURL) { + t.Errorf("missing auth registry URL %s", authRegistryURL) + } + authRegistryFQDN := fmt.Sprintf("%s.%s:%d", config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort) + if !strings.Contains(s, authRegistryFQDN) { + t.Errorf("missing auth registry FQDN %s", authRegistryFQDN) + } } func TestValidateYAML(t *testing.T) { diff --git a/internal/node/templates/user-data.yaml.tmpl b/internal/node/templates/user-data.yaml.tmpl index ba51563..30f3e75 100644 --- a/internal/node/templates/user-data.yaml.tmpl +++ b/internal/node/templates/user-data.yaml.tmpl @@ -42,7 +42,7 @@ write_files: - path: /etc/crio/crio.conf.d/03-local-registry.conf content: | [crio.image] - insecure_registries = ["{{.RegistryStaticIP}}:{{.RegistryPort}}", "{{.RegistryHostname}}.{{.ClusterDomain}}:{{.RegistryPort}}"] + insecure_registries = ["{{.RegistryStaticIP}}:{{.RegistryPort}}", "{{.RegistryHostname}}.{{.ClusterDomain}}:{{.RegistryPort}}", "{{.AuthRegistryStaticIP}}:{{.AuthRegistryPort}}", "{{.AuthRegistryHostname}}.{{.ClusterDomain}}:{{.AuthRegistryPort}}"] - path: /etc/containers/registries.conf.d/10-local-registry.conf content: | [[registry]] @@ -52,6 +52,14 @@ write_files: [[registry]] location = "{{.RegistryHostname}}.{{.ClusterDomain}}:{{.RegistryPort}}" insecure = true + + [[registry]] + location = "{{.AuthRegistryStaticIP}}:{{.AuthRegistryPort}}" + insecure = true + + [[registry]] + location = "{{.AuthRegistryHostname}}.{{.ClusterDomain}}:{{.AuthRegistryPort}}" + insecure = true - path: /etc/systemd/system/var-mnt-cluster_images.mount content: | [Unit] diff --git a/internal/registry/registry.go b/internal/registry/registry.go index f6f201b..88e975f 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -5,8 +5,10 @@ package registry import ( "context" + "errors" "fmt" "net" + "net/http" "strings" "github.com/bootc-dev/bink/internal/config" @@ -14,9 +16,13 @@ import ( "github.com/sirupsen/logrus" nettypes "go.podman.io/common/libnetwork/types" "go.podman.io/podman/v6/libpod/define" + "go.podman.io/podman/v6/pkg/errorhandling" "go.podman.io/podman/v6/pkg/specgen" + "golang.org/x/crypto/bcrypt" ) +const authHtpasswdEnv = "BINK_AUTH_HTPASSWD" + type Manager struct { podman *podman.Client } @@ -45,27 +51,16 @@ func (m *Manager) EnsureRegistry(ctx context.Context) error { return fmt.Errorf("checking registry container: %w", err) } + ensure := func(ctx context.Context) error { + return m.ensureExistingContainer(ctx, config.RegistryContainerName, "Registry") + } + if exists { - status, err := m.podman.ContainerStatus(ctx, config.RegistryContainerName) - if err != nil { - return fmt.Errorf("checking registry status: %w", err) - } - switch status { - case define.ContainerStateRunning.String(): - logrus.Info("Registry already running") - return nil - default: - logrus.Infof("Registry container is %s, starting it", status) - if err := m.podman.ContainerStart(ctx, config.RegistryContainerName); err != nil { - return fmt.Errorf("starting registry: %w", err) - } - logrus.Info("Registry started") - return nil - } + return ensure(ctx) } if err := m.createContainer(ctx); err != nil { - return err + return m.recoverFromConcurrentCreate(ctx, config.RegistryContainerName, err, ensure) } logrus.Infof("Registry running at %s:%d (host: localhost:%d)", @@ -96,6 +91,9 @@ func (m *Manager) createContainer(ctx context.Context) error { Options: []string{"z"}, }, }, + Environment: map[string]string{ + "REGISTRY_HTTP_SECRET": config.RegistryHTTPSecret, + }, Labels: map[string]string{ config.LabelComponent: "registry", }, @@ -103,37 +101,60 @@ func (m *Manager) createContainer(ctx context.Context) error { _, err := m.podman.ContainerCreate(ctx, opts) if err != nil { - if strings.Contains(err.Error(), "already in use") { - logrus.Info("Registry container was created concurrently") - return nil - } return fmt.Errorf("creating registry container: %w", err) } return nil } +// ensureExistingContainer starts a registry container that already exists, using label +// for user-facing log and error messages. +func (m *Manager) ensureExistingContainer(ctx context.Context, name, label string) error { + status, err := m.podman.ContainerStatus(ctx, name) + if err != nil { + return fmt.Errorf("checking %s status: %w", strings.ToLower(label), err) + } + if status == define.ContainerStateRunning.String() { + logrus.Infof("%s already running", label) + return nil + } + + logrus.Infof("%s container is %s, starting it", label, status) + if err := m.podman.ContainerStart(ctx, name); err != nil { + return fmt.Errorf("starting %s: %w", strings.ToLower(label), err) + } + logrus.Infof("%s started", label) + return nil +} + func (m *Manager) StopRegistry(ctx context.Context) error { exists, err := m.podman.ContainerExists(ctx, config.RegistryContainerName) if err != nil { return fmt.Errorf("checking registry container: %w", err) } - if !exists { - logrus.Info("Registry container not found") - return nil - } + if exists { + logrus.Info("Stopping registry container") + if err := m.podman.ContainerStop(ctx, config.RegistryContainerName); err != nil && !isPodmanNotFound(err) { + logrus.Warnf("Failed to stop registry: %v", err) + } - logrus.Info("Stopping registry container") - if err := m.podman.ContainerStop(ctx, config.RegistryContainerName); err != nil { - logrus.Warnf("Failed to stop registry: %v", err) + if err := m.podman.ContainerRemove(ctx, config.RegistryContainerName, true); err != nil && !isPodmanNotFound(err) { + return fmt.Errorf("removing registry container: %w", err) + } + } else { + logrus.Info("Registry container not found") } - if err := m.podman.ContainerRemove(ctx, config.RegistryContainerName, true); err != nil { - return fmt.Errorf("removing registry container: %w", err) + volumeExists, err := m.podman.VolumeExists(ctx, config.RegistryVolume) + if err != nil { + return fmt.Errorf("checking registry volume: %w", err) } - - if err := m.podman.VolumeRemove(ctx, config.RegistryVolume); err != nil { - logrus.Warnf("Failed to remove registry volume: %v", err) + if volumeExists { + if err := m.podman.VolumeRemove(ctx, config.RegistryVolume); err != nil && !isPodmanNotFound(err) { + return fmt.Errorf("removing registry volume: %w", err) + } + } else { + logrus.Info("Registry volume not found") } logrus.Info("Registry stopped and removed") @@ -173,3 +194,219 @@ func (m *Manager) RegistryInfo(ctx context.Context) (*RegistryStatus, error) { info.Running = status == define.ContainerStateRunning.String() return info, nil } + +// EnsureAuthRegistry starts (or creates) the authenticated registry. Credentials are not +// stored anywhere inspectable, so they cannot be compared against an already-running +// container: to change them, stop the registry and start it again. +func (m *Manager) EnsureAuthRegistry(ctx context.Context, username, password string) error { + logrus.Info("Ensuring authenticated registry is running") + if err := ValidateAuthCredentials(username, password); err != nil { + return err + } + + if err := m.podman.EnsureImage(ctx, config.RegistryImage); err != nil { + return fmt.Errorf("ensuring registry image: %w", err) + } + + if err := m.podman.VolumeCreate(ctx, config.RegistryVolume, nil); err != nil { + return fmt.Errorf("creating registry volume: %w", err) + } + + exists, err := m.podman.ContainerExists(ctx, config.AuthRegistryContainerName) + if err != nil { + return fmt.Errorf("checking auth registry container: %w", err) + } + + ensure := func(ctx context.Context) error { + return m.ensureExistingContainer(ctx, config.AuthRegistryContainerName, "Authenticated registry") + } + + if exists { + return ensure(ctx) + } + + if err := m.createAuthContainer(ctx, username, password); err != nil { + return m.recoverFromConcurrentCreate(ctx, config.AuthRegistryContainerName, err, ensure) + } + + logrus.Infof("Authenticated registry running at %s:%d (host: localhost:%d)", + config.AuthRegistryStaticIP, config.AuthRegistryPort, config.AuthRegistryPort) + return nil +} + +func (m *Manager) createAuthContainer(ctx context.Context, username, password string) error { + htpasswdEntry, err := generateHtpasswd(username, password) + if err != nil { + return fmt.Errorf("generating htpasswd: %w", err) + } + + opts := &podman.ContainerCreateOptions{ + Name: config.AuthRegistryContainerName, + Image: config.RegistryImage, + Entrypoint: []string{"/bin/sh", "-c", + `mkdir -p /auth && printf '%s\n' "$` + authHtpasswdEnv + `" > /auth/htpasswd && exec /entrypoint.sh /etc/docker/registry/config.yml`, + }, + NetworkOptions: map[string]nettypes.PerNetworkOptions{ + config.DefaultNetworkName: { + StaticIPs: []net.IP{net.ParseIP(config.AuthRegistryStaticIP)}, + }, + }, + PortMappings: []nettypes.PortMapping{ + { + HostPort: uint16(config.AuthRegistryPort), + ContainerPort: uint16(config.AuthRegistryPort), + Protocol: "tcp", + }, + }, + Volumes: []*specgen.NamedVolume{ + { + Name: config.RegistryVolume, + Dest: "/var/lib/registry", + Options: []string{"ro", "z"}, + }, + }, + Environment: map[string]string{ + authHtpasswdEnv: htpasswdEntry, + "REGISTRY_HTTP_ADDR": fmt.Sprintf("0.0.0.0:%d", config.AuthRegistryPort), + "REGISTRY_AUTH": "htpasswd", + "REGISTRY_AUTH_HTPASSWD_REALM": "Registry Realm", + "REGISTRY_AUTH_HTPASSWD_PATH": "/auth/htpasswd", + "REGISTRY_HTTP_SECRET": config.RegistryHTTPSecret, + }, + Labels: map[string]string{ + config.LabelComponent: "auth-registry", + }, + } + + _, err = m.podman.ContainerCreate(ctx, opts) + if err != nil { + return fmt.Errorf("creating auth registry container: %w", err) + } + return nil +} + +func (m *Manager) StopAuthRegistry(ctx context.Context) error { + exists, err := m.podman.ContainerExists(ctx, config.AuthRegistryContainerName) + if err != nil { + return fmt.Errorf("checking auth registry container: %w", err) + } + + if !exists { + logrus.Info("Auth registry container not found") + return nil + } + + logrus.Info("Stopping auth registry container") + if err := m.podman.ContainerStop(ctx, config.AuthRegistryContainerName); err != nil && !isPodmanNotFound(err) { + logrus.Warnf("Failed to stop auth registry: %v", err) + } + + if err := m.podman.ContainerRemove(ctx, config.AuthRegistryContainerName, true); err != nil && !isPodmanNotFound(err) { + return fmt.Errorf("removing auth registry container: %w", err) + } + + logrus.Info("Auth registry stopped and removed") + return nil +} + +type AuthRegistryStatus struct { + Running bool + IP string + HostPort int + PullURL string +} + +func (m *Manager) AuthRegistryInfo(ctx context.Context) (*AuthRegistryStatus, error) { + info := &AuthRegistryStatus{ + IP: config.AuthRegistryStaticIP, + HostPort: config.AuthRegistryPort, + PullURL: fmt.Sprintf("%s.%s:%d", config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort), + } + + exists, err := m.podman.ContainerExists(ctx, config.AuthRegistryContainerName) + if err != nil { + return info, fmt.Errorf("checking auth registry container: %w", err) + } + + if !exists { + return info, nil + } + + status, err := m.podman.ContainerStatus(ctx, config.AuthRegistryContainerName) + if err != nil { + return info, fmt.Errorf("checking auth registry status: %w", err) + } + + info.Running = status == define.ContainerStateRunning.String() + return info, nil +} + +func isPodmanNotFound(err error) bool { + var podmanErr *errorhandling.ErrorModel + return errors.As(err, &podmanErr) && podmanErr.ResponseCode == http.StatusNotFound +} + +// recoverFromConcurrentCreate handles parallel EnsureRegistry/EnsureAuthRegistry calls +// where two processes both attempt to create the same named container. +func (m *Manager) recoverFromConcurrentCreate(ctx context.Context, name string, createErr error, ensure func(context.Context) error) error { + if isContainerAlreadyExists(createErr) { + logrus.Infof("%s was created concurrently", name) + return ensure(ctx) + } + + exists, checkErr := m.podman.ContainerExists(ctx, name) + if checkErr != nil { + return errors.Join(createErr, fmt.Errorf("checking %s after create failure: %w", name, checkErr)) + } + if exists { + logrus.Infof("%s was created concurrently", name) + return ensure(ctx) + } + return createErr +} + +func isContainerAlreadyExists(err error) bool { + if errors.Is(err, define.ErrCtrExists) { + return true + } + var podmanErr *errorhandling.ErrorModel + if errors.As(err, &podmanErr) && podmanErr.ResponseCode == http.StatusConflict { + return true + } + msg := err.Error() + return strings.Contains(msg, "already in use") || strings.Contains(msg, define.ErrCtrExists.Error()) +} + +// ValidateAuthCredentials checks that credentials can be represented safely in an htpasswd file. +func ValidateAuthCredentials(username, password string) error { + if username == "" { + return fmt.Errorf("registry username must not be empty") + } + if strings.ContainsAny(username, ":\r\n") { + return fmt.Errorf("registry username must not contain ':', carriage returns, or newlines") + } + if password == "" { + return fmt.Errorf("registry password must not be empty") + } + return nil +} + +// AuthRegistryRequested reports whether credentials request an authenticated registry. +// Supplying only one credential is rejected rather than silently disabling authentication. +func AuthRegistryRequested(username, password string) (bool, error) { + if username == "" && password == "" { + return false, nil + } + if err := ValidateAuthCredentials(username, password); err != nil { + return false, err + } + return true, nil +} + +func generateHtpasswd(username, password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("hashing password: %w", err) + } + return fmt.Sprintf("%s:%s", username, string(hash)), nil +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..1db139d --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2026 The bink Authors +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "errors" + "fmt" + "net/http" + "strings" + "testing" + + . "github.com/onsi/gomega" + "go.podman.io/podman/v6/libpod/define" + "go.podman.io/podman/v6/pkg/errorhandling" + "golang.org/x/crypto/bcrypt" +) + +func TestIsContainerAlreadyExists(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "container already exists", + err: define.ErrCtrExists, + want: true, + }, + { + name: "wrapped container already exists", + err: fmt.Errorf("creating registry container: %w", define.ErrCtrExists), + want: true, + }, + { + name: "name already in use", + err: errors.New(`the container name "bink-registry" is already in use by abc123`), + want: true, + }, + { + name: "wrapped name already in use", + err: fmt.Errorf("creating container: %w", errors.New(`the container name "bink-registry" is already in use`)), + want: true, + }, + { + name: "conflict", + err: &errorhandling.ErrorModel{ + ResponseCode: http.StatusConflict, + }, + want: true, + }, + { + name: "not found", + err: &errorhandling.ErrorModel{ + ResponseCode: http.StatusNotFound, + }, + }, + { + name: "unrelated error", + err: errors.New("network unreachable"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + g.Expect(isContainerAlreadyExists(tt.err)).To(Equal(tt.want)) + }) + } +} + +func TestIsPodmanNotFound(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "not found", + err: &errorhandling.ErrorModel{ + ResponseCode: http.StatusNotFound, + }, + want: true, + }, + { + name: "wrapped not found", + err: fmt.Errorf("removing container: %w", &errorhandling.ErrorModel{ + ResponseCode: http.StatusNotFound, + }), + want: true, + }, + { + name: "conflict", + err: &errorhandling.ErrorModel{ + ResponseCode: http.StatusConflict, + }, + }, + { + name: "unstructured error", + err: errors.New("container not found"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + g.Expect(isPodmanNotFound(tt.err)).To(Equal(tt.want)) + }) + } +} + +func TestValidateAuthCredentials(t *testing.T) { + tests := []struct { + name string + username string + password string + wantErr string + }{ + {name: "valid", username: "test-user", password: "test-password"}, + {name: "empty username", password: "test-password", wantErr: "registry username must not be empty"}, + {name: "empty password", username: "test-user", wantErr: "registry password must not be empty"}, + {name: "colon in username", username: "test:user", password: "test-password", wantErr: "registry username must not contain ':', carriage returns, or newlines"}, + {name: "newline in username", username: "test\nuser", password: "test-password", wantErr: "registry username must not contain ':', carriage returns, or newlines"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + err := ValidateAuthCredentials(tt.username, tt.password) + if tt.wantErr == "" { + g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(err).To(MatchError(tt.wantErr)) + } + }) + } +} + +func TestAuthRegistryRequested(t *testing.T) { + tests := []struct { + name string + username string + password string + want bool + wantErr string + }{ + {name: "no credentials"}, + {name: "both credentials", username: "test-user", password: "test-password", want: true}, + {name: "username only", username: "test-user", wantErr: "registry password must not be empty"}, + {name: "password only", password: "test-password", wantErr: "registry username must not be empty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + got, err := AuthRegistryRequested(tt.username, tt.password) + if tt.wantErr == "" { + g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(err).To(MatchError(tt.wantErr)) + } + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func TestGenerateHtpasswd(t *testing.T) { + g := NewWithT(t) + entry, err := generateHtpasswd("test-user", "test-password") + g.Expect(err).ToNot(HaveOccurred()) + + username, passwordHash, found := strings.Cut(entry, ":") + g.Expect(found).To(BeTrue()) + g.Expect(username).To(Equal("test-user")) + g.Expect(bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte("test-password"))).To(Succeed()) +} diff --git a/test/integration/helpers/cluster.go b/test/integration/helpers/cluster.go index 7a7e206..ba02373 100644 --- a/test/integration/helpers/cluster.go +++ b/test/integration/helpers/cluster.go @@ -54,9 +54,11 @@ func RunCommand(cmd *exec.Cmd, timeout ...time.Duration) *gexec.Session { // CreateCluster creates a cluster with the given name // This is a high-level helper that expects success // Uses auto-assigned ports (--api-port 0) to avoid port conflicts in tests -func CreateCluster(name string) { +func CreateCluster(name string, extraArgs ...string) { GinkgoWriter.Printf("Creating cluster: %s (with auto-assigned API port)\n", name) - cmd := BinkCmd("cluster", "start", "--cluster-name", name, "--api-port", "0", "--memory", "1900", "--max-memory", "4096", "--node-image", NodeImage()) + args := []string{"cluster", "start", "--cluster-name", name, "--api-port", "0", "--memory", "1900", "--max-memory", "4096", "--node-image", NodeImage()} + args = append(args, extraArgs...) + cmd := BinkCmd(args...) session := RunCommand(cmd, 10*time.Minute) Expect(session.ExitCode()).To(Equal(0), "Failed to create cluster: %s", string(session.Err.Contents())) } diff --git a/test/integration/registry_test.go b/test/integration/registry_test.go index c6949f3..4835126 100644 --- a/test/integration/registry_test.go +++ b/test/integration/registry_test.go @@ -5,7 +5,10 @@ package integration_test import ( "context" + "encoding/base64" + "encoding/json" "fmt" + "net/http" "time" . "github.com/onsi/ginkgo/v2" @@ -17,6 +20,13 @@ import ( "github.com/bootc-dev/bink/test/integration/helpers" ) +const ( + registryTestNamespace = metav1.NamespaceDefault + registryTestPodName = "registry-test" + authRegistryTestPodName = "auth-registry-test" + podExecEchoMessage = "hello" +) + var _ = Describe("Local Registry", func() { var clusterName string @@ -56,8 +66,8 @@ var _ = Describe("Local Registry", func() { config.RegistryHostname, config.ClusterDomain, config.RegistryPort) pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "registry-test", - Labels: map[string]string{"run": "registry-test"}, + Name: registryTestPodName, + Labels: map[string]string{"run": registryTestPodName}, }, Spec: corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyNever, @@ -68,24 +78,147 @@ var _ = Describe("Local Registry", func() { }}, }, } - helpers.CreatePod(kubeClient, "default", pod, 5*time.Minute) + helpers.CreatePod(kubeClient, registryTestNamespace, pod, 5*time.Minute) By("Verifying the pod is running with the registry image") - runningPod, err := kubeClient.CoreV1().Pods("default").Get( - context.Background(), "registry-test", metav1.GetOptions{}) + runningPod, err := kubeClient.CoreV1().Pods(registryTestNamespace).Get( + context.Background(), registryTestPodName, metav1.GetOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(runningPod.Status.Phase).To(Equal(corev1.PodRunning)) Expect(runningPod.Spec.Containers[0].Image).To(Equal(registryImage)) By("Verifying the container is functional by running a command inside it") - Eventually(func() string { - result, _ := helpers.PodExec(kubeconfigPath, "default", "registry-test", - []string{"echo", "hello"}) - return result - }, 1*time.Minute, 5*time.Second).Should(ContainSubstring("hello")) + Eventually(func() (string, error) { + return helpers.PodExec(kubeconfigPath, registryTestNamespace, registryTestPodName, + []string{"echo", podExecEchoMessage}) + }, 1*time.Minute, 5*time.Second).Should(ContainSubstring(podExecEchoMessage)) By("Cleaning up the pod") - helpers.DeletePod(kubeClient, "default", "registry-test") + helpers.DeletePod(kubeClient, registryTestNamespace, registryTestPodName) + + By("Cleaning up the local registry tag") + helpers.ImageRemove(registryTag) + }) + + It("should pull from the authenticated registry using imagePullSecrets", func() { + authRegistryUser := "integration-" + clusterName + authRegistryPassword := "password-" + clusterName + + By("Requiring an unused authenticated registry") + Expect(helpers.ContainerExists(config.AuthRegistryContainerName)).To(BeFalse(), + "authenticated registry already exists; stop it before running this test") + + By("Starting an authenticated registry with test-specific credentials") + startSession := helpers.RunCommand(helpers.BinkCmd( + "registry", "start", "--auth", + "--registry-user", authRegistryUser, + "--registry-password", authRegistryPassword, + )) + Expect(startSession.ExitCode()).To(Equal(0), "Failed to start authenticated registry") + + DeferCleanup(func() { + session := helpers.RunCommand(helpers.BinkCmd("registry", "stop", "--auth")) + Expect(session.ExitCode()).To(Equal(0), "Failed to clean up authenticated registry") + }) + + By("Creating a single-node cluster") + helpers.CreateCluster(clusterName, + "--registry-user", authRegistryUser, + "--registry-password", authRegistryPassword) + + By("Pulling busybox image locally") + helpers.ImagePull(config.TestBusyboxImage) + + registryTag := fmt.Sprintf("localhost:%d/busybox:auth-registry-test", config.RegistryPort) + + By("Tagging busybox for the local registry") + helpers.ImageTag(config.TestBusyboxImage, "auth-registry-test", + fmt.Sprintf("localhost:%d/busybox", config.RegistryPort)) + + By("Pushing busybox to the unauthenticated registry") + helpers.ImagePush(registryTag, registryTag) + + By("Verifying the authenticated registry rejects anonymous requests") + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Get(fmt.Sprintf("http://localhost:%d/v2/", config.AuthRegistryPort)) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(response.Body.Close) + Expect(response.StatusCode).To(Equal(http.StatusUnauthorized)) + + By("Exposing API and creating Kubernetes client") + kubeClient, kubeconfigPath := helpers.SetupKubeClient(clusterName) + defer helpers.CleanupKubeconfig(kubeconfigPath) + + By("Removing control-plane taint to allow scheduling on single-node cluster") + helpers.RemoveControlPlaneTaint(kubeClient, "node1") + + By("Creating docker-registry secret with auth registry credentials") + authServer := fmt.Sprintf("%s.%s:%d", + config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort) + authEncoded := base64.StdEncoding.EncodeToString( + []byte(authRegistryUser + ":" + authRegistryPassword)) + dockerConfig := map[string]any{ + "auths": map[string]any{ + authServer: map[string]any{ + "username": authRegistryUser, + "password": authRegistryPassword, + "auth": authEncoded, + }, + }, + } + dockerConfigJSON, err := json.Marshal(dockerConfig) + Expect(err).ToNot(HaveOccurred()) + + secretName := "auth-registry-secret" + _, err = kubeClient.CoreV1().Secrets(registryTestNamespace).Create( + context.Background(), + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: dockerConfigJSON}, + }, + metav1.CreateOptions{}, + ) + Expect(err).ToNot(HaveOccurred()) + + By("Deploying a pod that pulls from the authenticated registry") + authRegistryImage := fmt.Sprintf("%s.%s:%d/busybox:auth-registry-test", + config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: authRegistryTestPodName, + Labels: map[string]string{"run": authRegistryTestPodName}, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + ImagePullSecrets: []corev1.LocalObjectReference{{Name: secretName}}, + Containers: []corev1.Container{{ + Name: "busybox", + Image: authRegistryImage, + ImagePullPolicy: corev1.PullAlways, + Command: []string{"sh", "-c", "echo 'auth-registry-pull-success' && sleep 3600"}, + }}, + }, + } + helpers.CreatePod(kubeClient, registryTestNamespace, pod, 5*time.Minute) + + By("Verifying the pod is running with the auth registry image") + runningPod, err := kubeClient.CoreV1().Pods(registryTestNamespace).Get( + context.Background(), authRegistryTestPodName, metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(runningPod.Status.Phase).To(Equal(corev1.PodRunning)) + Expect(runningPod.Spec.Containers[0].Image).To(Equal(authRegistryImage)) + + By("Verifying the container is functional by running a command inside it") + Eventually(func() (string, error) { + return helpers.PodExec(kubeconfigPath, registryTestNamespace, authRegistryTestPodName, + []string{"echo", podExecEchoMessage}) + }, 1*time.Minute, 5*time.Second).Should(ContainSubstring(podExecEchoMessage)) + + By("Cleaning up the pod and secret") + helpers.DeletePod(kubeClient, registryTestNamespace, authRegistryTestPodName) + Expect(kubeClient.CoreV1().Secrets(registryTestNamespace).Delete( + context.Background(), secretName, metav1.DeleteOptions{})).To(Succeed()) By("Cleaning up the local registry tag") helpers.ImageRemove(registryTag)