From cb501092e2222febc0a85723073a8f1beb101801 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 1 Oct 2026 13:29:33 +0200 Subject: [PATCH 1/9] feat(project): support custom Kind configuration and Docker network Signed-off-by: Nikita Z --- apis/dev/v1alpha1/project_types.go | 31 ++++++++ cmd/crossplane/project/run.go | 48 +++++++++++- internal/project/controlplane/controlplane.go | 76 ++++++++++++++----- 3 files changed, 137 insertions(+), 18 deletions(-) diff --git a/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go index c696e97a..4b7c104e 100644 --- a/apis/dev/v1alpha1/project_types.go +++ b/apis/dev/v1alpha1/project_types.go @@ -117,6 +117,37 @@ type ProjectSpec struct { // any necessary ImageConfigs for deployment into a cluster must be created // separately at deployment time. ImageConfigs []pkgv1beta1.ImageConfig `json:"imageConfigs,omitempty"` + + // Runtime configures the local development cluster + Runtime Runtime `json:"runtime,omitempty"` +} + +// Runtime is the spec for project runtime configuration. +type Runtime struct { + // Runtime configuration options for KinD + Kind RuntimeKind `json:"kind,omitempty"` +} + +// RuntimeKind adds configuration options for KinD +type RuntimeKind struct { + // The KinD configuration to use for local development cluster. + Config RuntimeConfig `json:"config,omitempty"` + // Use internal addresses in the exported kubeconfig. + // Enable if running crossplane project in a container. + Internal bool `json:"internal,omitempty"` + // The docker network to start up the local development cluster in. + Network RuntimeNetwork `json:"network,omitempty"` +} + +// RuntimeConfig is the spec for a runtime configuration. +type RuntimeConfig struct { + // The path to the configuration file. + Path string `json:"path,omitempty"` +} + +// RuntimeNetwork is the spec for runtime network configuration. +type RuntimeNetwork struct { + Name string `json:"name,omitempty"` } // ProjectPackageMetadata holds metadata about the project, which will become diff --git a/cmd/crossplane/project/run.go b/cmd/crossplane/project/run.go index 73ba0ad7..122922cb 100644 --- a/cmd/crossplane/project/run.go +++ b/cmd/crossplane/project/run.go @@ -22,8 +22,10 @@ import ( "maps" "os" "path/filepath" + "strings" "time" + "github.com/alecthomas/kong" "github.com/google/go-containerregistry/pkg/name" "github.com/spf13/afero" "golang.org/x/sync/errgroup" @@ -31,6 +33,7 @@ import ( "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "sigs.k8s.io/controller-runtime/pkg/scheme" + "sigs.k8s.io/kind/pkg/apis/config/v1alpha4" "sigs.k8s.io/yaml" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -69,6 +72,9 @@ type runCmd struct { ControlPlaneName string `help:"Name of the dev control plane. Defaults to project name."` CrossplaneVersion string `help:"Version of Crossplane to install."` + DockerNetwork string `help:"The docker network to start up the dev control plane in. Defaults to kind. This is an experimental feature in KinD."` + Internal bool `help:"Use internal addresses in the exported kubeconfig. Enable if running crossplane project in a container."` + KindConfig string `help:"The path to the KinD configuration which should be used to create the local development cluster."` RegistryDir string `help:"Directory for local registry images."` ClusterAdmin bool `default:"true" help:"Grant Crossplane the cluster-admin role." negatable:""` DefaultMRAP bool `default:"true" help:"Install the default wildcard ManagedResourceActivationPolicy in the dev control plane." negatable:""` @@ -81,6 +87,7 @@ type runCmd struct { initResources []runtime.RawExtension extraResources []runtime.RawExtension + kindConfig *v1alpha4.Cluster } func (c *runCmd) Help() string { @@ -130,13 +137,49 @@ func (c *runCmd) AfterApply() error { } } + if len(strings.TrimSpace(c.KindConfig)) == 0 { + c.KindConfig = c.proj.Spec.Runtime.Kind.Config.Path + } + + if len(strings.TrimSpace(c.KindConfig)) > 0 { + kindCfgBytes, err := afero.ReadFile(c.projFS, c.KindConfig) + if err != nil { + return errors.Wrapf(err, "failed to load kind configuration from %q", c.KindConfig) + } + + kindCfg := &v1alpha4.Cluster{} + + err = yaml.Unmarshal(kindCfgBytes, kindCfg) + if err != nil { + return errors.Wrapf(err, "failed to unmarshal KinD configuration from %q", c.KindConfig) + } + + c.kindConfig = kindCfg + } + return nil } // Run executes the run command. -func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocyclo // Main command orchestration. +func (c *runCmd) Run(kongCtx *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocyclo // Main command orchestration. ctx := context.Background() + internalSet := false + for _, flag := range kongCtx.Flags() { + if flag.Name == "internal" { + internalSet = flag.Set + break + } + } + + if !internalSet { + c.Internal = c.proj.Spec.Runtime.Kind.Internal + } + + if c.DockerNetwork == "" && len(strings.TrimSpace(c.proj.Spec.Runtime.Kind.Network.Name)) > 0 { + c.DockerNetwork = c.proj.Spec.Runtime.Kind.Network.Name + } + if c.Repository != "" { ref, err := name.NewRepository(c.Repository) if err != nil { @@ -222,6 +265,9 @@ func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con controlplane.WithClusterAdmin(c.ClusterAdmin), controlplane.WithDefaultMRAP(c.DefaultMRAP), controlplane.WithLogger(logger), + controlplane.WithDockerNetwork(c.DockerNetwork), + controlplane.WithInternal(c.Internal), + controlplane.WithKindConfig(c.kindConfig), ) if ctpErr != nil { ch.SendEvent("Setting up control plane", async.EventStatusFailure) diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 75225f4a..e2f54cb0 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -26,6 +26,7 @@ import ( "path" "path/filepath" "slices" + "strings" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/empty" @@ -211,6 +212,9 @@ type config struct { clusterAdmin bool defaultMRAP bool log logging.Logger + kindConfig *v1alpha4.Cluster + internal bool + dockerNetwork string } // WithName sets the name of the local dev control plane. @@ -257,6 +261,28 @@ func WithLogger(l logging.Logger) Option { } } +// WithProjectConfig sets the crossplane project configuration. +func WithKindConfig(kc *v1alpha4.Cluster) Option { + return func(c *config) { + c.kindConfig = kc + } +} + +// WithInternal configures the local development controlplane to use internal addresses +// in the exported kubeconfig. Set this to true when running crossplane project in a container. +func WithInternal(internal bool) Option { + return func(c *config) { + c.internal = internal + } +} + +// WithDockerNetwork configures which docker network to start up the local development control plane in. +func WithDockerNetwork(network string) Option { + return func(c *config) { + c.dockerNetwork = network + } +} + // EnsureLocalDevControlPlane creates or reuses a local kind-based development // control plane with Crossplane installed. func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControlPlane, error) { //nolint:gocyclo // Main orchestration function. @@ -280,8 +306,8 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl nameLen = min(nameLen, 63-len("-control-plane")) cfg.name = cfg.name[:nameLen] - cfg.log.Debug("Ensuring kind cluster", "name", cfg.name) - kubeconfig, err := ensureKindCluster(cfg.name) + cfg.log.Debug("Ensuring kind cluster", "name", cfg.name, "internal", cfg.internal, "network", cfg.dockerNetwork) + kubeconfig, err := ensureKindCluster(*cfg) if err != nil { return nil, err } @@ -328,7 +354,11 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl } cfg.log.Debug("Ensuring local registry container") - cid, err := ensureLocalRegistry(ctx, cl, regName, registryDir, certSecret) + networkName := cfg.dockerNetwork + if networkName == "" { + networkName = "kind" + } + cid, err := ensureLocalRegistry(ctx, cl, regName, registryDir, certSecret, networkName) if err != nil { return nil, err } @@ -400,13 +430,14 @@ func TeardownLocalDevControlPlane(ctx context.Context, name string, registryDir return nil } -func ensureKindCluster(clusterName string) (clientcmd.ClientConfig, error) { +func ensureKindCluster(cfg config) (clientcmd.ClientConfig, error) { provider := kind.NewProvider() kubeconfigFile, err := os.CreateTemp("", "crossplane-*.kubeconfig") if err != nil { return nil, errors.Wrap(err, "failed to create temporary kubeconfig") } + _ = kubeconfigFile.Close() defer func() { _ = os.Remove(kubeconfigFile.Name()) }() @@ -415,16 +446,16 @@ func ensureKindCluster(clusterName string) (clientcmd.ClientConfig, error) { return nil, errors.Wrap(err, "failed to list kind clusters") } - if slices.Contains(existing, clusterName) { - if err := provider.ExportKubeConfig(clusterName, kubeconfigFile.Name(), false); err != nil { - return nil, errors.Wrap(err, "failed to get kubeconfig for kind cluster") - } - } else { - if err := createNewKindCluster(provider, clusterName, kubeconfigFile.Name()); err != nil { + if !slices.Contains(existing, cfg.name) { + if err := createNewKindCluster(provider, cfg, kubeconfigFile.Name()); err != nil { return nil, err } } + if err := provider.ExportKubeConfig(cfg.name, kubeconfigFile.Name(), cfg.internal); err != nil { + return nil, errors.Wrap(err, "failed to get kubeconfig for kind cluster") + } + kubeconfigBytes, err := os.ReadFile(kubeconfigFile.Name()) if err != nil { return nil, errors.Wrap(err, "failed to load kubeconfig") @@ -438,16 +469,27 @@ func ensureKindCluster(clusterName string) (clientcmd.ClientConfig, error) { return kubeconfig, nil } -func createNewKindCluster(provider *kind.Provider, clusterName, kubeconfigPath string) error { +func createNewKindCluster(provider *kind.Provider, c config, kubeconfigPath string) error { cfg := createKindClusterConfig() + if c.kindConfig != nil { + cfg = c.kindConfig + } + cfgBytes, err := yaml.Marshal(cfg) if err != nil { return errors.Wrap(err, "failed to marshal kind config") } + if len(strings.TrimSpace(c.dockerNetwork)) > 0 { + err := os.Setenv("KIND_EXPERIMENTAL_DOCKER_NETWORK", c.dockerNetwork) + if err != nil { + return errors.Wrap(err, "failed to set docker network") + } + } + if err := provider.Create( - clusterName, + c.name, kind.CreateWithRawConfig(cfgBytes), kind.CreateWithNodeImage(defaults.Image), kind.CreateWithDisplayUsage(false), @@ -522,7 +564,7 @@ func ensureCrossplane(restConfig *rest.Config, version, caConfigMap string, clus return nil } -func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir string, certSecret *corev1.Secret) (string, error) { +func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir string, certSecret *corev1.Secret, networkName string) (string, error) { const regImage = "ghcr.io/olareg/olareg:edge" certDir := filepath.Join(dir, ".certs") @@ -560,13 +602,13 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str return "", errors.New("failed to write tls key") } - // Find kind's network. - nid, found, err := docker.GetNetworkIDByName(ctx, "kind") + // Find the cluster's docker network, so the registry can join it. + nid, found, err := docker.GetNetworkIDByName(ctx, networkName) if err != nil { - return "", errors.Wrap(err, "failed to get kind network ID") + return "", errors.Wrap(err, "failed to get docker network ID") } if !found { - return "", errors.New("missing kind network") + return "", errors.Errorf("missing docker network %q", networkName) } // Start the registry container. From 41899ced2b778e5803b47d87ceb45e6cfaef259b Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 1 Oct 2026 13:29:33 +0200 Subject: [PATCH 2/9] fix(project): copy registry data instead of bind-mounting it Bind-mounting the local registry directory into the registry container relies on the CLI's filesystem being visible to the Docker daemon. That breaks when Crossplane itself runs inside a container, since the mount path only exists in the CLI's own filesystem, not the daemon's. Signed-off-by: Nikita Z --- internal/docker/docker.go | 91 +++++++++++++++++++ internal/project/controlplane/controlplane.go | 33 ++++++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 3ae056af..6eed446b 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -26,6 +26,8 @@ import ( "encoding/json" "fmt" "io" + "io/fs" + "os" "path" "path/filepath" "slices" @@ -214,6 +216,85 @@ func StartContainerByID(ctx context.Context, id string) error { return errors.Wrap(err, "failed to start container") } +// TarDirectory creates a Docker-compatible tarball containing a directory's +// contents. +func TarDirectory(dir string) ([]byte, error) { + buf := new(bytes.Buffer) + tw := tar.NewWriter(buf) + defer func() { _ = tw.Close() }() + + if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == dir { + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + return errors.Errorf("refusing to copy symbolic link %q", path) + } + + info, err := d.Info() + if err != nil { + return err + } + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + header.Name = filepath.ToSlash(rel) + if d.IsDir() { + header.Name += "/" + } + if err := tw.WriteHeader(header); err != nil { + return err + } + if !d.Type().IsRegular() { + return nil + } + + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() //nolint:errcheck // Best-effort close while walking the directory. + _, err = io.Copy(tw, in) + return err + }); err != nil { + return nil, errors.Wrap(err, "failed to create directory tarball") + } + if err := tw.Close(); err != nil { + return nil, errors.Wrap(err, "failed to close directory tarball") + } + + return buf.Bytes(), nil +} + +// CopyDirectoryToContainer copies a directory's contents to a container path. +func CopyDirectoryToContainer(ctx context.Context, id, source, destination string) error { + tarball, err := TarDirectory(source) + if err != nil { + return err + } + + cli, err := NewClient() + if err != nil { + return err + } + if _, err := cli.CopyToContainer(ctx, id, client.CopyToContainerOptions{ + DestinationPath: filepath.Clean(destination), + Content: bytes.NewReader(tarball), + }); err != nil { + return errors.Wrapf(err, "failed to copy directory to container path %s", destination) + } + + return nil +} + type startContainerConfig struct { containerConfig *container.Config hostConfig *container.HostConfig @@ -249,6 +330,16 @@ func StartWithBindMount(hostPath, containerPath string) StartContainerOption { } } +// StartWithVolume adds a Docker-managed volume at the supplied container path. +func StartWithVolume(path string) StartContainerOption { + return func(cfg *startContainerConfig) { + if cfg.containerConfig.Volumes == nil { + cfg.containerConfig.Volumes = map[string]struct{}{} + } + cfg.containerConfig.Volumes[path] = struct{}{} + } +} + // StartWithNetworkID adds a network to which a container should be added. func StartWithNetworkID(nid string) StartContainerOption { return func(cfg *startContainerConfig) { diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index e2f54cb0..2701b998 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -59,6 +59,7 @@ import ( const ( crossplaneNamespace = "crossplane-system" + registryDataDir = "/registry-data" ) // DevControlPlane is a local development control plane. @@ -122,6 +123,11 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return err } + // Paths written below, copied to the registry container once permissions + // have been fixed up. Copying only these paths (rather than the whole of + // l.registryDir) keeps sideloading fast as the local image cache grows. + var written []string + for repo, images := range fnImages { p := filepath.Join(l.registryDir, repo.RepositoryStr()) if err := os.MkdirAll(p, 0o750); err != nil { @@ -143,6 +149,8 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag })); err != nil { return err } + + written = append(written, p) } p := filepath.Join(l.registryDir, tag.RepositoryStr()) @@ -161,6 +169,8 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return err } + written = append(written, p) + // Make everything world-readable for unprivileged container access. if err := filepath.WalkDir(l.registryDir, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -176,6 +186,17 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return errors.Wrap(err, "failed to adjust permissions on sideloaded images") } + for _, p := range written { + rel, err := filepath.Rel(l.registryDir, p) + if err != nil { + return errors.Wrap(err, "failed to determine registry-relative path") + } + dest := path.Join(registryDataDir, filepath.ToSlash(rel)) + if err := docker.CopyDirectoryToContainer(ctx, l.registryContainerID, p, dest); err != nil { + return errors.Wrap(err, "failed to copy images to local registry") + } + } + rewrite := path.Join(l.registryHostname, tag.RepositoryStr()) imgcfg := &pkgv1beta1.ImageConfig{ ObjectMeta: metav1.ObjectMeta{ @@ -577,6 +598,9 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str //nolint:gosec // We don't do anything dangerous with the CA data. caData, err := os.ReadFile(filepath.Join(certDir, "ca.crt")) if err == nil && bytes.Equal(caData, certSecret.Data[certs.SecretKeyCACert]) { + if err := docker.CopyDirectoryToContainer(ctx, existing, certDir, path.Join(registryDataDir, ".certs")); err != nil { + return "", errors.Wrap(err, "failed to copy certificates to existing registry container") + } if err := docker.StartContainerByID(ctx, existing); err != nil { return "", errors.Wrap(err, "failed to start existing registry container") } @@ -601,6 +625,10 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str if err := os.WriteFile(filepath.Join(certDir, "tls.key"), certSecret.Data[corev1.TLSPrivateKeyKey], 0o644); err != nil { //nolint:gosec // Container needs to read the file. return "", errors.New("failed to write tls key") } + certTarball, err := docker.TarDirectory(certDir) + if err != nil { + return "", errors.Wrap(err, "failed to archive registry certificates") + } // Find the cluster's docker network, so the registry can join it. nid, found, err := docker.GetNetworkIDByName(ctx, networkName) @@ -613,8 +641,9 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str // Start the registry container. cid, err := docker.StartContainer(ctx, regName, regImage, - docker.StartWithCommand([]string{"serve", "--dir=/registry-data", "--api-push=false", "--store-ro", "--tls-cert=/registry-data/.certs/tls.crt", "--tls-key=/registry-data/.certs/tls.key"}), - docker.StartWithBindMount(dir, "/registry-data"), + docker.StartWithCommand([]string{"serve", "--dir=" + registryDataDir, "--api-push=false", "--store-ro", "--tls-cert=" + path.Join(registryDataDir, ".certs", "tls.crt"), "--tls-key=" + path.Join(registryDataDir, ".certs", "tls.key")}), + docker.StartWithVolume(registryDataDir), + docker.StartWithCopyFiles(certTarball, path.Join(registryDataDir, ".certs")), docker.StartWithNetworkID(nid), ) if err != nil { From 0df8e7466e813909b2c807bb16f801cbc0a4eb1f Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 1 Oct 2026 13:29:33 +0200 Subject: [PATCH 3/9] fix: default to kind network if networkName is empty Signed-off-by: Nikita Z --- internal/project/controlplane/controlplane.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 2701b998..feb09ffb 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -631,6 +631,10 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str } // Find the cluster's docker network, so the registry can join it. + if len(strings.TrimSpace(networkName)) == 0 { + networkName = "kind" + } + nid, found, err := docker.GetNetworkIDByName(ctx, networkName) if err != nil { return "", errors.Wrap(err, "failed to get docker network ID") From 268f037da5436baea310b348e175ee915843ef71 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 1 Oct 2026 13:29:34 +0200 Subject: [PATCH 4/9] chore: use go-archive instead of manually tarring Signed-off-by: Nikita Z --- go.mod | 5 +++ go.sum | 14 ++++++++ internal/docker/docker.go | 73 +++++++++------------------------------ 3 files changed, 36 insertions(+), 56 deletions(-) diff --git a/go.mod b/go.mod index bfabe1df..5b6e48b9 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/google/ko v0.18.1 github.com/invopop/jsonschema v0.14.0 github.com/kubernetes-sigs/kro v0.9.2 + github.com/moby/go-archive v0.3.3 github.com/moby/moby/api v1.56.0 github.com/moby/moby/client v0.5.1 github.com/muesli/termenv v0.16.0 @@ -222,6 +223,10 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.1 // indirect + github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect diff --git a/go.sum b/go.sum index de06ec2d..6873b6e3 100644 --- a/go.sum +++ b/go.sum @@ -591,10 +591,24 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= +github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00= +github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M= +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.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= +github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 6eed446b..4542db15 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -26,13 +26,13 @@ import ( "encoding/json" "fmt" "io" - "io/fs" - "os" "path" "path/filepath" "slices" "strings" + archive "github.com/moby/go-archive" + "github.com/docker/cli/cli/config" "github.com/google/go-containerregistry/pkg/name" "github.com/moby/moby/api/pkg/stdcopy" @@ -219,75 +219,36 @@ func StartContainerByID(ctx context.Context, id string) error { // TarDirectory creates a Docker-compatible tarball containing a directory's // contents. func TarDirectory(dir string) ([]byte, error) { - buf := new(bytes.Buffer) - tw := tar.NewWriter(buf) - defer func() { _ = tw.Close() }() - - if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == dir { - return nil - } - if d.Type()&fs.ModeSymlink != 0 { - return errors.Errorf("refusing to copy symbolic link %q", path) - } - - info, err := d.Info() - if err != nil { - return err - } - header, err := tar.FileInfoHeader(info, "") - if err != nil { - return err - } - rel, err := filepath.Rel(dir, path) - if err != nil { - return err - } - header.Name = filepath.ToSlash(rel) - if d.IsDir() { - header.Name += "/" - } - if err := tw.WriteHeader(header); err != nil { - return err - } - if !d.Type().IsRegular() { - return nil - } - - in, err := os.Open(path) - if err != nil { - return err - } - defer in.Close() //nolint:errcheck // Best-effort close while walking the directory. - _, err = io.Copy(tw, in) - return err - }); err != nil { - return nil, errors.Wrap(err, "failed to create directory tarball") + rd, err := archive.TarWithOptions(dir, &archive.TarOptions{}) + if err != nil { + return nil, errors.Wrapf(err, "failed to archive %s", dir) } - if err := tw.Close(); err != nil { - return nil, errors.Wrap(err, "failed to close directory tarball") + defer rd.Close() //nolint:errcheck // Best-effort close after draining the archive. + + b, err := io.ReadAll(rd) + if err != nil { + return nil, errors.Wrap(err, "failed to read directory tarball") } - return buf.Bytes(), nil + return b, nil } // CopyDirectoryToContainer copies a directory's contents to a container path. func CopyDirectoryToContainer(ctx context.Context, id, source, destination string) error { - tarball, err := TarDirectory(source) + cli, err := NewClient() if err != nil { return err } - cli, err := NewClient() + tarball, err := archive.TarWithOptions(source, &archive.TarOptions{}) if err != nil { - return err + return errors.Wrapf(err, "failed to archive %s", source) } + defer tarball.Close() //nolint:errcheck // Best-effort close after streaming the archive. + if _, err := cli.CopyToContainer(ctx, id, client.CopyToContainerOptions{ DestinationPath: filepath.Clean(destination), - Content: bytes.NewReader(tarball), + Content: tarball, }); err != nil { return errors.Wrapf(err, "failed to copy directory to container path %s", destination) } From 34d365806e8a89be94945e52f2eed2ba38d548a5 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 1 Oct 2026 13:29:34 +0200 Subject: [PATCH 5/9] chore: simplify the internal flag check Signed-off-by: Nikita Z --- cmd/crossplane/project/run.go | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/cmd/crossplane/project/run.go b/cmd/crossplane/project/run.go index 122922cb..d7663502 100644 --- a/cmd/crossplane/project/run.go +++ b/cmd/crossplane/project/run.go @@ -25,7 +25,6 @@ import ( "strings" "time" - "github.com/alecthomas/kong" "github.com/google/go-containerregistry/pkg/name" "github.com/spf13/afero" "golang.org/x/sync/errgroup" @@ -161,18 +160,10 @@ func (c *runCmd) AfterApply() error { } // Run executes the run command. -func (c *runCmd) Run(kongCtx *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocyclo // Main command orchestration. +func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocyclo // Main command orchestration. ctx := context.Background() - internalSet := false - for _, flag := range kongCtx.Flags() { - if flag.Name == "internal" { - internalSet = flag.Set - break - } - } - - if !internalSet { + if !c.Internal && c.proj.Spec.Runtime.Kind.Internal { c.Internal = c.proj.Spec.Runtime.Kind.Internal } From e272f266dcd30632698e4aba164f9840eda0892b Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 1 Oct 2026 13:29:34 +0200 Subject: [PATCH 6/9] chore: update StartWithVolume comment Signed-off-by: Nikita Z --- internal/docker/docker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 4542db15..e3dcb2e7 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -291,7 +291,7 @@ func StartWithBindMount(hostPath, containerPath string) StartContainerOption { } } -// StartWithVolume adds a Docker-managed volume at the supplied container path. +// StartWithVolume adds a volume when starting a container func StartWithVolume(path string) StartContainerOption { return func(cfg *startContainerConfig) { if cfg.containerConfig.Volumes == nil { From d534e7433d1150962b885cae159b8a0e0009c680 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 1 Oct 2026 14:50:00 +0200 Subject: [PATCH 7/9] fix(project): copy registry data into docker volume Signed-off-by: Nikita Z --- internal/docker/docker.go | 13 ++++---- internal/project/controlplane/controlplane.go | 30 +++++-------------- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/internal/docker/docker.go b/internal/docker/docker.go index e3dcb2e7..ecaaf5c5 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -216,10 +216,10 @@ func StartContainerByID(ctx context.Context, id string) error { return errors.Wrap(err, "failed to start container") } -// TarDirectory creates a Docker-compatible tarball containing a directory's -// contents. +// TarDirectory tars a directory. func TarDirectory(dir string) ([]byte, error) { - rd, err := archive.TarWithOptions(dir, &archive.TarOptions{}) + base := filepath.Base(dir) + rd, err := archive.TarWithOptions(filepath.Dir(dir), archive.TarResourceRebaseOpts(base, base)) if err != nil { return nil, errors.Wrapf(err, "failed to archive %s", dir) } @@ -233,14 +233,17 @@ func TarDirectory(dir string) ([]byte, error) { return b, nil } -// CopyDirectoryToContainer copies a directory's contents to a container path. +// CopyDirectoryToContainer copies a directory tree to an existing container +// directory, preserving its nested paths. func CopyDirectoryToContainer(ctx context.Context, id, source, destination string) error { cli, err := NewClient() if err != nil { return err } - tarball, err := archive.TarWithOptions(source, &archive.TarOptions{}) + tarball, err := archive.TarWithOptions(source, &archive.TarOptions{ + IncludeSourceDir: true, + }) if err != nil { return errors.Wrapf(err, "failed to archive %s", source) } diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index feb09ffb..9b61dc0d 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -59,7 +59,6 @@ import ( const ( crossplaneNamespace = "crossplane-system" - registryDataDir = "/registry-data" ) // DevControlPlane is a local development control plane. @@ -123,11 +122,6 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return err } - // Paths written below, copied to the registry container once permissions - // have been fixed up. Copying only these paths (rather than the whole of - // l.registryDir) keeps sideloading fast as the local image cache grows. - var written []string - for repo, images := range fnImages { p := filepath.Join(l.registryDir, repo.RepositoryStr()) if err := os.MkdirAll(p, 0o750); err != nil { @@ -150,7 +144,6 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return err } - written = append(written, p) } p := filepath.Join(l.registryDir, tag.RepositoryStr()) @@ -169,8 +162,6 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return err } - written = append(written, p) - // Make everything world-readable for unprivileged container access. if err := filepath.WalkDir(l.registryDir, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -186,15 +177,8 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return errors.Wrap(err, "failed to adjust permissions on sideloaded images") } - for _, p := range written { - rel, err := filepath.Rel(l.registryDir, p) - if err != nil { - return errors.Wrap(err, "failed to determine registry-relative path") - } - dest := path.Join(registryDataDir, filepath.ToSlash(rel)) - if err := docker.CopyDirectoryToContainer(ctx, l.registryContainerID, p, dest); err != nil { - return errors.Wrap(err, "failed to copy images to local registry") - } + if err := docker.CopyDirectoryToContainer(ctx, l.registryContainerID, l.registryDir, l.registryDir); err != nil { + return errors.Wrap(err, "failed to copy images to local registry") } rewrite := path.Join(l.registryHostname, tag.RepositoryStr()) @@ -598,7 +582,7 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str //nolint:gosec // We don't do anything dangerous with the CA data. caData, err := os.ReadFile(filepath.Join(certDir, "ca.crt")) if err == nil && bytes.Equal(caData, certSecret.Data[certs.SecretKeyCACert]) { - if err := docker.CopyDirectoryToContainer(ctx, existing, certDir, path.Join(registryDataDir, ".certs")); err != nil { + if err := docker.CopyDirectoryToContainer(ctx, existing, dir, dir); err != nil { return "", errors.Wrap(err, "failed to copy certificates to existing registry container") } if err := docker.StartContainerByID(ctx, existing); err != nil { @@ -625,7 +609,7 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str if err := os.WriteFile(filepath.Join(certDir, "tls.key"), certSecret.Data[corev1.TLSPrivateKeyKey], 0o644); err != nil { //nolint:gosec // Container needs to read the file. return "", errors.New("failed to write tls key") } - certTarball, err := docker.TarDirectory(certDir) + certTar, err := docker.TarDirectory(certDir) if err != nil { return "", errors.Wrap(err, "failed to archive registry certificates") } @@ -645,9 +629,9 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str // Start the registry container. cid, err := docker.StartContainer(ctx, regName, regImage, - docker.StartWithCommand([]string{"serve", "--dir=" + registryDataDir, "--api-push=false", "--store-ro", "--tls-cert=" + path.Join(registryDataDir, ".certs", "tls.crt"), "--tls-key=" + path.Join(registryDataDir, ".certs", "tls.key")}), - docker.StartWithVolume(registryDataDir), - docker.StartWithCopyFiles(certTarball, path.Join(registryDataDir, ".certs")), + docker.StartWithCommand([]string{"serve", "--dir=" + dir, "--api-push=false", "--store-ro", "--tls-cert=" + path.Join(dir, ".certs", "tls.crt"), "--tls-key=" + path.Join(dir, ".certs", "tls.key")}), + docker.StartWithVolume(dir), + docker.StartWithCopyFiles(certTar, dir), docker.StartWithNetworkID(nid), ) if err != nil { From 2b1280e39c04a47a8a698361fa850beca20e16d0 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Fri, 2 Oct 2026 16:39:06 +0200 Subject: [PATCH 8/9] refactor: add storage interface for registry storage and configflag to select stoarge-type the bindmount implementation ensures that we keep old behavior, and the volume implementation is for DinD support Signed-off-by: Nikita Z --- apis/dev/v1alpha1/project_types.go | 20 ++- cmd/crossplane/project/run.go | 23 +++- internal/docker/docker.go | 36 +++--- internal/docker/storage.go | 95 +++++++++++++++ internal/project/controlplane/controlplane.go | 115 ++++++++++++------ 5 files changed, 225 insertions(+), 64 deletions(-) create mode 100644 internal/docker/storage.go diff --git a/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go index 4b7c104e..82d786bd 100644 --- a/apis/dev/v1alpha1/project_types.go +++ b/apis/dev/v1alpha1/project_types.go @@ -118,17 +118,17 @@ type ProjectSpec struct { // separately at deployment time. ImageConfigs []pkgv1beta1.ImageConfig `json:"imageConfigs,omitempty"` - // Runtime configures the local development cluster + // Runtime configures the local development cluster. Runtime Runtime `json:"runtime,omitempty"` } // Runtime is the spec for project runtime configuration. type Runtime struct { - // Runtime configuration options for KinD - Kind RuntimeKind `json:"kind,omitempty"` + Kind RuntimeKind `json:"kind,omitempty"` + Registry RegistryConfig `json:"registry,omitempty"` } -// RuntimeKind adds configuration options for KinD +// RuntimeKind is the runtime configuration options for KinD. type RuntimeKind struct { // The KinD configuration to use for local development cluster. Config RuntimeConfig `json:"config,omitempty"` @@ -150,6 +150,18 @@ type RuntimeNetwork struct { Name string `json:"name,omitempty"` } +// RegistryConfig is the configuration options for the local registry +type RegistryConfig struct { + Storage StorageConfig `json:"storage,omitempty"` +} + +// StorageConfig is the configuration options for storage +type StorageConfig struct { + // The type of storage to use. + // Options: "bindMount" (default), "volume". + Type string `json:"type,omitempty"` +} + // ProjectPackageMetadata holds metadata about the project, which will become // package metadata when a project is built into a Crossplane package. type ProjectPackageMetadata struct { diff --git a/cmd/crossplane/project/run.go b/cmd/crossplane/project/run.go index d7663502..792fdee5 100644 --- a/cmd/crossplane/project/run.go +++ b/cmd/crossplane/project/run.go @@ -46,6 +46,7 @@ import ( "github.com/crossplane/cli/v2/internal/async" "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/docker" "github.com/crossplane/cli/v2/internal/project" "github.com/crossplane/cli/v2/internal/project/controlplane" "github.com/crossplane/cli/v2/internal/project/functions" @@ -87,6 +88,7 @@ type runCmd struct { initResources []runtime.RawExtension extraResources []runtime.RawExtension kindConfig *v1alpha4.Cluster + storageType docker.StorageType } func (c *runCmd) Help() string { @@ -147,9 +149,7 @@ func (c *runCmd) AfterApply() error { } kindCfg := &v1alpha4.Cluster{} - - err = yaml.Unmarshal(kindCfgBytes, kindCfg) - if err != nil { + if err := yaml.Unmarshal(kindCfgBytes, kindCfg); err != nil { return errors.Wrapf(err, "failed to unmarshal KinD configuration from %q", c.KindConfig) } @@ -163,7 +163,7 @@ func (c *runCmd) AfterApply() error { func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocyclo // Main command orchestration. ctx := context.Background() - if !c.Internal && c.proj.Spec.Runtime.Kind.Internal { + if !c.Internal { c.Internal = c.proj.Spec.Runtime.Kind.Internal } @@ -183,6 +183,11 @@ func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con c.ControlPlaneName = "crossplane-" + c.proj.Name } + c.storageType = docker.StorageTypeBindMount + if c.proj.Spec.Runtime.Registry.Storage.Type == string(docker.StorageTypeVolume) { + c.storageType = docker.StorageTypeVolume + } + concurrency := max(1, c.MaxConcurrency) schemasFS := afero.NewBasePathFs(c.projFS, c.proj.Spec.Paths.Schemas) @@ -259,6 +264,7 @@ func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con controlplane.WithDockerNetwork(c.DockerNetwork), controlplane.WithInternal(c.Internal), controlplane.WithKindConfig(c.kindConfig), + controlplane.WithStorageType(c.storageType), ) if ctpErr != nil { ch.SendEvent("Setting up control plane", async.EventStatusFailure) @@ -308,6 +314,15 @@ func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con return errors.Wrap(err, "failed to sideload packages") } + storage := devCtp.Storage() + if storage == nil { + return errors.Errorf("registry storage is not initialized") + } + + if err := storage.Sync(ctx, devCtp.RegistryContainerID()); err != nil { + return errors.Wrapf(err, "failed to sync registry data to volume") + } + // Apply init resources. if len(c.initResources) > 0 { logger.Debug("Applying init resources") diff --git a/internal/docker/docker.go b/internal/docker/docker.go index ecaaf5c5..9def1257 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -216,25 +216,7 @@ func StartContainerByID(ctx context.Context, id string) error { return errors.Wrap(err, "failed to start container") } -// TarDirectory tars a directory. -func TarDirectory(dir string) ([]byte, error) { - base := filepath.Base(dir) - rd, err := archive.TarWithOptions(filepath.Dir(dir), archive.TarResourceRebaseOpts(base, base)) - if err != nil { - return nil, errors.Wrapf(err, "failed to archive %s", dir) - } - defer rd.Close() //nolint:errcheck // Best-effort close after draining the archive. - - b, err := io.ReadAll(rd) - if err != nil { - return nil, errors.Wrap(err, "failed to read directory tarball") - } - - return b, nil -} - -// CopyDirectoryToContainer copies a directory tree to an existing container -// directory, preserving its nested paths. +// CopyDirectoryToContainer copies a directory tree to an existing container directory. func CopyDirectoryToContainer(ctx context.Context, id, source, destination string) error { cli, err := NewClient() if err != nil { @@ -643,6 +625,22 @@ func TarFromContainer(ctx context.Context, cid, path string) ([]byte, error) { return io.ReadAll(resp.Content) } +// TarDirectory tars a directory. +func TarDirectory(dir string) ([]byte, error) { + rd, err := archive.TarWithOptions(dir, &archive.TarOptions{}) //archive.TarResourceRebaseOpts(base, base)) + if err != nil { + return nil, errors.Wrapf(err, "failed to archive %s", dir) + } + defer rd.Close() //nolint:errcheck // Best-effort close after draining the archive. + + b, err := io.ReadAll(rd) + if err != nil { + return nil, errors.Wrap(err, "failed to read directory tarball") + } + + return b, nil +} + // NewClient creates a new Docker client configured from environment variables. func NewClient() (*client.Client, error) { cli, err := client.New(client.FromEnv) diff --git a/internal/docker/storage.go b/internal/docker/storage.go new file mode 100644 index 00000000..ef6111c1 --- /dev/null +++ b/internal/docker/storage.go @@ -0,0 +1,95 @@ +package docker + +import ( + "context" + "fmt" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +type StorageType string + +const ( + StorageTypeBindMount StorageType = "bindMount" + StorageTypeVolume StorageType = "volume" +) + +type Storage interface { + // ContainerOptions returns the required docker start options for its storage type. + ContainerOptions() []StartContainerOption + + // Sync ensures the specified directory is synced to the container. + Sync(ctx context.Context, containerId string) error + + // Returns the destination directory of the storage. + DestDir() string +} + +var ( + _ Storage = (*DockerVolumeStorage)(nil) + _ Storage = (*BindMountStorage)(nil) +) + +// BindMountStorage provides storage operations for bind-mounts, which mounts a directory on the host machine to a registry container. +type BindMountStorage struct { + sourceDir string + destDir string +} + +func NewBindMountStorage(sourceDir, destDir string) *BindMountStorage { + return &BindMountStorage{ + sourceDir: sourceDir, + destDir: destDir, + } +} + +func (s BindMountStorage) ContainerOptions() []StartContainerOption { + return []StartContainerOption{ + StartWithBindMount(s.sourceDir, s.destDir), + } +} + +func (s BindMountStorage) Sync(_ context.Context, _ string) error { + // The source directory is mounted, no need for sync + return nil +} + +func (s BindMountStorage) DestDir() string { + return s.destDir +} + +// DockerVolumeStorage provides storage operations for volume-based registry storage. +// This is necessary for DinD scenarios, where bind-mount is unusable cause the bound file-system is +// the file system of the docker daemon, not the running cli process. +type DockerVolumeStorage struct { + sourceDir string + destDir string + initFiles []byte +} + +func NewVolumeStorage(sourceDir, destDir string, initFiles []byte) *DockerVolumeStorage { + return &DockerVolumeStorage{ + sourceDir: sourceDir, + destDir: destDir, + initFiles: initFiles, + } +} + +func (s DockerVolumeStorage) ContainerOptions() []StartContainerOption { + return []StartContainerOption{ + StartWithVolume(s.destDir), + StartWithCopyFiles(s.initFiles, s.destDir), + } +} + +func (s DockerVolumeStorage) Sync(ctx context.Context, containerId string) error { + if err := CopyDirectoryToContainer(ctx, containerId, s.sourceDir, s.destDir); err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to copy directory %q to %q", s.sourceDir, containerId)) + } + + return nil +} + +func (s DockerVolumeStorage) DestDir() string { + return s.destDir +} diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 9b61dc0d..f5fad32f 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -73,8 +73,14 @@ type DevControlPlane interface { Teardown(ctx context.Context) error // Sideload sideloads packages into the control plane. Sideload(ctx context.Context, imgMap project.ImageTagMap, tag name.Tag) error + // Returns the storage used by the control plane registry container. + Storage() docker.Storage + // Returns the container id of the control plane registry. + RegistryContainerID() string } +var _ DevControlPlane = (*localDevControlPlane)(nil) + type localDevControlPlane struct { name string kubeconfig clientcmd.ClientConfig @@ -82,6 +88,7 @@ type localDevControlPlane struct { registryDir string registryContainerID string registryHostname string + registryStorage docker.Storage } func (l *localDevControlPlane) Info() string { @@ -143,7 +150,6 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag })); err != nil { return err } - } p := filepath.Join(l.registryDir, tag.RepositoryStr()) @@ -177,10 +183,6 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return errors.Wrap(err, "failed to adjust permissions on sideloaded images") } - if err := docker.CopyDirectoryToContainer(ctx, l.registryContainerID, l.registryDir, l.registryDir); err != nil { - return errors.Wrap(err, "failed to copy images to local registry") - } - rewrite := path.Join(l.registryHostname, tag.RepositoryStr()) imgcfg := &pkgv1beta1.ImageConfig{ ObjectMeta: metav1.ObjectMeta{ @@ -207,6 +209,14 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return nil } +func (l *localDevControlPlane) Storage() docker.Storage { + return l.registryStorage +} + +func (l *localDevControlPlane) RegistryContainerID() string { + return l.registryContainerID +} + // Option configures EnsureLocalDevControlPlane. type Option func(*config) @@ -220,6 +230,7 @@ type config struct { kindConfig *v1alpha4.Cluster internal bool dockerNetwork string + storageType docker.StorageType } // WithName sets the name of the local dev control plane. @@ -288,6 +299,17 @@ func WithDockerNetwork(network string) Option { } } +// WithStorageType configures which kind of storage type to use in the local registry. +func WithStorageType(storageType docker.StorageType) Option { + return func(c *config) { + c.storageType = storageType + } +} + +const ( + certDirName string = ".certs" +) + // EnsureLocalDevControlPlane creates or reuses a local kind-based development // control plane with Crossplane installed. func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControlPlane, error) { //nolint:gocyclo // Main orchestration function. @@ -295,6 +317,7 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl clusterAdmin: true, defaultMRAP: true, log: logging.NewNopLogger(), + storageType: docker.StorageTypeBindMount, } for _, opt := range opts { opt(cfg) @@ -347,8 +370,7 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl return nil, errors.Wrap(err, "cannot generate certificate for registry") } - // Create a directory to store sideloaded images and spin up a registry - // container that uses it. + // Create a directory to store cert and sideloaded images. registryDir := cfg.registryDir if registryDir == "" { registryDir = filepath.Join(os.TempDir(), "crossplane-local-registry") @@ -358,12 +380,44 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl return nil, err } + // Write the TLS cert and key files. + certDir := filepath.Join(registryDir, certDirName) + if err := os.MkdirAll(certDir, 0o755); err != nil { //nolint:gosec // Container needs to read the dir. + return nil, errors.New("failed to create cert directory") + } + if err := os.WriteFile(filepath.Join(certDir, "ca.crt"), certSecret.Data[certs.SecretKeyCACert], 0o644); err != nil { //nolint:gosec // Container needs to read the file. + return nil, errors.New("failed to write ca cert") + } + if err := os.WriteFile(filepath.Join(certDir, "tls.crt"), certSecret.Data[corev1.TLSCertKey], 0o644); err != nil { //nolint:gosec // Container needs to read the file. + return nil, errors.New("failed to write tls cert") + } + if err := os.WriteFile(filepath.Join(certDir, "tls.key"), certSecret.Data[corev1.TLSPrivateKeyKey], 0o644); err != nil { //nolint:gosec // Container needs to read the file. + return nil, errors.New("failed to write tls key") + } + + // Create docker registry storage of the specified type (bind-mount or volume). + destDir := "/registry-data" + var storage docker.Storage + switch cfg.storageType { + case docker.StorageTypeBindMount: + storage = docker.NewBindMountStorage(registryDir, destDir) + case docker.StorageTypeVolume: + certTarball, err := docker.TarDirectory(registryDir) + if err != nil { + return nil, errors.Wrap(err, "failed to tar cert-directory") + } + storage = docker.NewVolumeStorage(registryDir, destDir, certTarball) + default: + return nil, errors.Errorf("unknown registry storage type %q", cfg.storageType) + } + cfg.log.Debug("Ensuring local registry container") networkName := cfg.dockerNetwork if networkName == "" { networkName = "kind" } - cid, err := ensureLocalRegistry(ctx, cl, regName, registryDir, certSecret, networkName) + + cid, err := ensureLocalRegistry(ctx, cl, storage, regName, registryDir, certSecret, networkName) if err != nil { return nil, err } @@ -381,6 +435,7 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl registryDir: registryDir, registryContainerID: cid, registryHostname: regName + ":5000", + registryStorage: storage, }, nil } @@ -442,7 +497,6 @@ func ensureKindCluster(cfg config) (clientcmd.ClientConfig, error) { if err != nil { return nil, errors.Wrap(err, "failed to create temporary kubeconfig") } - _ = kubeconfigFile.Close() defer func() { _ = os.Remove(kubeconfigFile.Name()) }() @@ -569,9 +623,9 @@ func ensureCrossplane(restConfig *rest.Config, version, caConfigMap string, clus return nil } -func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir string, certSecret *corev1.Secret, networkName string) (string, error) { +func ensureLocalRegistry(ctx context.Context, cl client.Client, storage docker.Storage, regName, dir string, certSecret *corev1.Secret, networkName string) (string, error) { const regImage = "ghcr.io/olareg/olareg:edge" - certDir := filepath.Join(dir, ".certs") + var certDir string = filepath.Join(dir, certDirName) // Check for existing registry container. existing, found, err := docker.GetContainerIDByName(ctx, regName, true) @@ -582,9 +636,6 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str //nolint:gosec // We don't do anything dangerous with the CA data. caData, err := os.ReadFile(filepath.Join(certDir, "ca.crt")) if err == nil && bytes.Equal(caData, certSecret.Data[certs.SecretKeyCACert]) { - if err := docker.CopyDirectoryToContainer(ctx, existing, dir, dir); err != nil { - return "", errors.Wrap(err, "failed to copy certificates to existing registry container") - } if err := docker.StartContainerByID(ctx, existing); err != nil { return "", errors.Wrap(err, "failed to start existing registry container") } @@ -596,24 +647,6 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str } } - // Write the TLS cert and key files. - if err := os.MkdirAll(certDir, 0o755); err != nil { //nolint:gosec // Container needs to read the dir. - return "", errors.New("failed to create cert directory") - } - if err := os.WriteFile(filepath.Join(certDir, "ca.crt"), certSecret.Data[certs.SecretKeyCACert], 0o644); err != nil { //nolint:gosec // Container needs to read the file. - return "", errors.New("failed to write ca cert") - } - if err := os.WriteFile(filepath.Join(certDir, "tls.crt"), certSecret.Data[corev1.TLSCertKey], 0o644); err != nil { //nolint:gosec // Container needs to read the file. - return "", errors.New("failed to write tls cert") - } - if err := os.WriteFile(filepath.Join(certDir, "tls.key"), certSecret.Data[corev1.TLSPrivateKeyKey], 0o644); err != nil { //nolint:gosec // Container needs to read the file. - return "", errors.New("failed to write tls key") - } - certTar, err := docker.TarDirectory(certDir) - if err != nil { - return "", errors.Wrap(err, "failed to archive registry certificates") - } - // Find the cluster's docker network, so the registry can join it. if len(strings.TrimSpace(networkName)) == 0 { networkName = "kind" @@ -628,12 +661,20 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir str } // Start the registry container. - cid, err := docker.StartContainer(ctx, regName, regImage, - docker.StartWithCommand([]string{"serve", "--dir=" + dir, "--api-push=false", "--store-ro", "--tls-cert=" + path.Join(dir, ".certs", "tls.crt"), "--tls-key=" + path.Join(dir, ".certs", "tls.key")}), - docker.StartWithVolume(dir), - docker.StartWithCopyFiles(certTar, dir), + startOptions := []docker.StartContainerOption{ + docker.StartWithCommand([]string{ + "serve", + "--dir=" + storage.DestDir(), + "--api-push=false", + "--store-ro", + "--tls-cert=" + filepath.Join(storage.DestDir(), certDirName, "tls.crt"), + "--tls-key=" + filepath.Join(storage.DestDir(), certDirName, "tls.key"), + }), docker.StartWithNetworkID(nid), - ) + } + startOptions = append(startOptions, storage.ContainerOptions()...) + + cid, err := docker.StartContainer(ctx, regName, regImage, startOptions...) if err != nil { return "", errors.Wrap(err, "failed to start registry container") } From 2b2d53ada0b6ebabb1f15be5f466be62f2b6a10f Mon Sep 17 00:00:00 2001 From: nkzk Date: Fri, 2 Oct 2026 19:25:52 +0200 Subject: [PATCH 9/9] chore: nix run tidy Signed-off-by: Nikita Z --- nix/vendor-hashes.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/vendor-hashes.nix b/nix/vendor-hashes.nix index 65849b3e..ccc23c4d 100644 --- a/nix/vendor-hashes.nix +++ b/nix/vendor-hashes.nix @@ -12,5 +12,5 @@ # hand.) { # Root module: github.com/crossplane/cli/v2 - root = "sha256-6mWzuyq9PqJniswsNdi5LJl4QLe7XcyWM+s+UHcDkiE="; + root = "sha256-LaEUdiN2/rOlfMHVwCdGyaPXCuA62J3LjPkysHPX3BU="; }