From cb501092e2222febc0a85723073a8f1beb101801 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 1 Oct 2026 13:29:33 +0200 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 07/20] 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 ba0f07e72e1d3e6caeb61321aa3f8b53e894fc1c Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Fri, 2 Oct 2026 16:39:06 +0200 Subject: [PATCH 08/20] refactor: add storage interface for registry storage and configflag to select storage-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 | 41 +++++-- internal/docker/docker.go | 41 +++---- internal/docker/storage.go | 105 ++++++++++++++++ internal/project/controlplane/controlplane.go | 116 ++++++++++++------ nix/vendor-hashes.nix | 2 +- 6 files changed, 246 insertions(+), 79 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..3d011a85 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..cc0d690d 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" @@ -75,11 +76,11 @@ type runCmd struct { 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:""` - Timeout time.Duration `default:"5m" help:"Max wait for project readiness."` - InitResources []string `help:"Resources to apply before installing." type:"path"` - ExtraResources []string `help:"Resources to apply after installing." type:"path"` + 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:""` + Timeout time.Duration `default:"5m" help:"Max wait for project readiness."` + InitResources []string `help:"Resources to apply before installing." type:"path"` + ExtraResources []string `help:"Resources to apply after installing." type:"path"` proj *devv1alpha1.Project projFS afero.Fs @@ -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) } @@ -159,11 +159,9 @@ func (c *runCmd) AfterApply() error { 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. - ctx := context.Background() - - if !c.Internal && c.proj.Spec.Runtime.Kind.Internal { +// resolveRunOptions applies command overrides and project runtime defaults. +func (c *runCmd) resolveRunOptions() error { + if !c.Internal { c.Internal = c.proj.Spec.Runtime.Kind.Internal } @@ -183,6 +181,22 @@ 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 + } + + 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. + ctx := context.Background() + + if err := c.resolveRunOptions(); err != nil { + return err + } + concurrency := max(1, c.MaxConcurrency) schemasFS := afero.NewBasePathFs(c.projFS, c.proj.Spec.Paths.Schemas) @@ -259,6 +273,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) diff --git a/internal/docker/docker.go b/internal/docker/docker.go index ecaaf5c5..8450e6c4 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -31,10 +31,9 @@ import ( "slices" "strings" - archive "github.com/moby/go-archive" - "github.com/docker/cli/cli/config" "github.com/google/go-containerregistry/pkg/name" + archive "github.com/moby/go-archive" "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/network" @@ -216,25 +215,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 { @@ -294,7 +275,7 @@ func StartWithBindMount(hostPath, containerPath string) StartContainerOption { } } -// StartWithVolume adds a volume when starting a container +// StartWithVolume adds a volume when starting a container. func StartWithVolume(path string) StartContainerOption { return func(cfg *startContainerConfig) { if cfg.containerConfig.Volumes == nil { @@ -643,6 +624,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..dc3f4908 --- /dev/null +++ b/internal/docker/storage.go @@ -0,0 +1,105 @@ +package docker + +import ( + "context" + "fmt" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// StorageType identifies the storage backend used by a container. +type StorageType string + +// Supported container storage backends. +const ( + StorageTypeBindMount StorageType = "bindMount" + StorageTypeVolume StorageType = "volume" +) + +// Storage configures container storage and synchronizes files to it. +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 = (*VolumeStorage)(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 +} + +// NewBindMountStorage creates storage that bind-mounts the source directory into the container. +func NewBindMountStorage(sourceDir, destDir string) *BindMountStorage { + return &BindMountStorage{ + sourceDir: sourceDir, + destDir: destDir, + } +} + +// ContainerOptions returns the options for bind-mounting the source directory. +func (s BindMountStorage) ContainerOptions() []StartContainerOption { + return []StartContainerOption{ + StartWithBindMount(s.sourceDir, s.destDir), + } +} + +// Sync is a no-op because the source directory is already bind-mounted. +func (s BindMountStorage) Sync(_ context.Context, _ string) error { + return nil +} + +// DestDir returns the directory where the storage is mounted in the container. +func (s BindMountStorage) DestDir() string { + return s.destDir +} + +// VolumeStorage 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 VolumeStorage struct { + sourceDir string + destDir string + initFiles []byte +} + +// NewVolumeStorage creates volume storage initialized with the supplied file archive. +func NewVolumeStorage(sourceDir, destDir string, initFiles []byte) *VolumeStorage { + return &VolumeStorage{ + sourceDir: sourceDir, + destDir: destDir, + initFiles: initFiles, + } +} + +// ContainerOptions returns the options for mounting and initializing the volume. +func (s VolumeStorage) ContainerOptions() []StartContainerOption { + return []StartContainerOption{ + StartWithVolume(s.destDir), + StartWithCopyFiles(s.initFiles, s.destDir), + } +} + +// Sync copies the source directory to the volume in the specified container. +func (s VolumeStorage) 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 +} + +// DestDir returns the directory where the storage is mounted in the container. +func (s VolumeStorage) DestDir() string { + return s.destDir +} diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 9b61dc0d..40c2a295 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -75,6 +75,8 @@ type DevControlPlane interface { Sideload(ctx context.Context, imgMap project.ImageTagMap, tag name.Tag) error } +var _ DevControlPlane = (*localDevControlPlane)(nil) + type localDevControlPlane struct { name string kubeconfig clientcmd.ClientConfig @@ -82,6 +84,7 @@ type localDevControlPlane struct { registryDir string registryContainerID string registryHostname string + registryStorage docker.Storage } func (l *localDevControlPlane) Info() string { @@ -143,7 +146,6 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag })); err != nil { return err } - } p := filepath.Join(l.registryDir, tag.RepositoryStr()) @@ -177,10 +179,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{ @@ -204,6 +202,14 @@ func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.Imag return errors.Wrap(err, "failed to create image config") } + if l.registryStorage == nil { + return errors.Errorf("registry storage is not initialized") + } + + if err := l.registryStorage.Sync(ctx, l.registryContainerID); err != nil { + return errors.Wrap(err, "failed to sync packages to registry") + } + return nil } @@ -220,6 +226,7 @@ type config struct { kindConfig *v1alpha4.Cluster internal bool dockerNetwork string + storageType docker.StorageType } // WithName sets the name of the local dev control plane. @@ -266,7 +273,7 @@ func WithLogger(l logging.Logger) Option { } } -// WithProjectConfig sets the crossplane project configuration. +// WithKindConfig sets the kind cluster configuration used to create the local dev control plane. func WithKindConfig(kc *v1alpha4.Cluster) Option { return func(c *config) { c.kindConfig = kc @@ -288,6 +295,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 +313,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 +366,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 +376,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 +431,7 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl registryDir: registryDir, registryContainerID: cid, registryHostname: regName + ":5000", + registryStorage: storage, }, nil } @@ -442,7 +493,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 +619,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") + certDir := filepath.Join(dir, certDirName) // Check for existing registry container. existing, found, err := docker.GetContainerIDByName(ctx, regName, true) @@ -582,9 +632,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 +643,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 +657,21 @@ 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), - docker.StartWithNetworkID(nid), - ) + storageOptions := storage.ContainerOptions() + startOptions := make([]docker.StartContainerOption, 0, 2+len(storageOptions)) + startOptions = append(startOptions, + 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, storageOptions...) + + cid, err := docker.StartContainer(ctx, regName, regImage, startOptions...) if err != nil { return "", errors.Wrap(err, "failed to start registry container") } 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="; } From 916b31e05da7b530382b7e6874239d8d5abdb867 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Wed, 7 Oct 2026 10:18:54 +0200 Subject: [PATCH 09/20] fix(project): validate storage type input Signed-off-by: Nikita Z --- cmd/crossplane/project/run.go | 8 ++++++-- internal/docker/storage.go | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/run.go b/cmd/crossplane/project/run.go index cc0d690d..b4ed6a28 100644 --- a/cmd/crossplane/project/run.go +++ b/cmd/crossplane/project/run.go @@ -182,8 +182,12 @@ func (c *runCmd) resolveRunOptions() error { } c.storageType = docker.StorageTypeBindMount - if c.proj.Spec.Runtime.Registry.Storage.Type == string(docker.StorageTypeVolume) { - c.storageType = docker.StorageTypeVolume + storageType := c.proj.Spec.Runtime.Registry.Storage.Type + if len(strings.TrimSpace(storageType)) > 0 { + if !docker.IsValidStorageType(storageType) { + return errors.Errorf("%q is an invalid storage type. Supported storage types: %v", storageType, docker.ValidStorageTypes) + } + c.storageType = docker.StorageType(storageType) } return nil diff --git a/internal/docker/storage.go b/internal/docker/storage.go index dc3f4908..5a73013b 100644 --- a/internal/docker/storage.go +++ b/internal/docker/storage.go @@ -16,6 +16,21 @@ const ( StorageTypeVolume StorageType = "volume" ) +var ValidStorageTypes = []string{ + string(StorageTypeBindMount), + string(StorageTypeVolume), +} + +func IsValidStorageType(storageType string) bool { + for _, s := range ValidStorageTypes { + if s == storageType { + return true + } + } + + return false +} + // Storage configures container storage and synchronizes files to it. type Storage interface { // ContainerOptions returns the required docker start options for its storage type. From 84f8d6d6fc99dea5f02ea01a9e6d0036a656c75f Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Wed, 7 Oct 2026 13:28:28 +0200 Subject: [PATCH 10/20] fix(project): use bool ptr for --internal flag Signed-off-by: Nikita Z --- cmd/crossplane/project/run.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/crossplane/project/run.go b/cmd/crossplane/project/run.go index b4ed6a28..af2b830c 100644 --- a/cmd/crossplane/project/run.go +++ b/cmd/crossplane/project/run.go @@ -73,7 +73,7 @@ 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."` + 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:""` @@ -161,8 +161,8 @@ func (c *runCmd) AfterApply() error { // resolveRunOptions applies command overrides and project runtime defaults. func (c *runCmd) resolveRunOptions() error { - if !c.Internal { - c.Internal = c.proj.Spec.Runtime.Kind.Internal + if c.Internal == nil { + c.Internal = &c.proj.Spec.Runtime.Kind.Internal } if c.DockerNetwork == "" && len(strings.TrimSpace(c.proj.Spec.Runtime.Kind.Network.Name)) > 0 { @@ -275,7 +275,7 @@ func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con controlplane.WithDefaultMRAP(c.DefaultMRAP), controlplane.WithLogger(logger), controlplane.WithDockerNetwork(c.DockerNetwork), - controlplane.WithInternal(c.Internal), + controlplane.WithInternal(*c.Internal), controlplane.WithKindConfig(c.kindConfig), controlplane.WithStorageType(c.storageType), ) From 285722a086539c1091dcc359dd2c89acb741bd23 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 8 Oct 2026 10:48:24 +0200 Subject: [PATCH 11/20] fix(project): recreate local registry when its CA changes Signed-off-by: Nikita Z --- internal/docker/storage.go | 2 ++ internal/project/controlplane/controlplane.go | 31 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/internal/docker/storage.go b/internal/docker/storage.go index 5a73013b..dfa233dd 100644 --- a/internal/docker/storage.go +++ b/internal/docker/storage.go @@ -16,11 +16,13 @@ const ( StorageTypeVolume StorageType = "volume" ) +// var ValidStorageTypes = []string{ string(StorageTypeBindMount), string(StorageTypeVolume), } +// IsValidStorageType returns true if the supplied storageType string is one of the supported storagetypes. func IsValidStorageType(storageType string) bool { for _, s := range ValidStorageTypes { if s == storageType { diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 40c2a295..081e2473 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -381,6 +381,22 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl 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") } + // Check the persisted CA before replacing it. A registry initialized with a + // different CA must be recreated so its storage and the cluster trust agree. + existing, found, err := docker.GetContainerIDByName(ctx, regName, true) + if err != nil { + return nil, errors.Wrap(err, "failed to look up existing registry container") + } + if found { + //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 := teardownLocalRegistry(ctx, existing); err != nil { + return nil, errors.Wrap(err, "failed to tear down outdated registry") + } + } + } + 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") } @@ -621,7 +637,6 @@ func ensureCrossplane(restConfig *rest.Config, version, caConfigMap string, clus 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, certDirName) // Check for existing registry container. existing, found, err := docker.GetContainerIDByName(ctx, regName, true) @@ -629,18 +644,10 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, storage docker.S return "", errors.Wrap(err, "failed to look up existing registry container") } if found { - //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.StartContainerByID(ctx, existing); err != nil { - return "", errors.Wrap(err, "failed to start existing registry container") - } - return existing, nil - } - - if err := teardownLocalRegistry(ctx, existing); err != nil { - return "", errors.Wrap(err, "failed to tear down outdated registry") + if err := docker.StartContainerByID(ctx, existing); err != nil { + return "", errors.Wrap(err, "failed to start existing registry container") } + return existing, nil } // Find the cluster's docker network, so the registry can join it. From ef6ead979d92ebd1bb12354a3c889434e902ff07 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 8 Oct 2026 13:55:51 +0200 Subject: [PATCH 12/20] fix(project): ensure that existing project cluster joins a new specified network Signed-off-by: Nikita Z --- internal/project/controlplane/controlplane.go | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 081e2473..3b5675b2 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -31,6 +31,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/layout" + mobyclient "github.com/moby/moby/client" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" kerrors "k8s.io/apimachinery/pkg/api/errors" @@ -331,7 +332,7 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl cfg.name = cfg.name[:nameLen] cfg.log.Debug("Ensuring kind cluster", "name", cfg.name, "internal", cfg.internal, "network", cfg.dockerNetwork) - kubeconfig, err := ensureKindCluster(*cfg) + kubeconfig, err := ensureKindCluster(ctx, *cfg) if err != nil { return nil, err } @@ -502,7 +503,7 @@ func TeardownLocalDevControlPlane(ctx context.Context, name string, registryDir return nil } -func ensureKindCluster(cfg config) (clientcmd.ClientConfig, error) { +func ensureKindCluster(ctx context.Context, cfg config) (clientcmd.ClientConfig, error) { provider := kind.NewProvider() kubeconfigFile, err := os.CreateTemp("", "crossplane-*.kubeconfig") @@ -521,6 +522,14 @@ func ensureKindCluster(cfg config) (clientcmd.ClientConfig, error) { if err := createNewKindCluster(provider, cfg, kubeconfigFile.Name()); err != nil { return nil, err } + } else { + networkName := cfg.dockerNetwork + if networkName == "" { + networkName = "kind" + } + if err := ensureKindClusterNetwork(ctx, provider, cfg.name, networkName); err != nil { + return nil, err + } } if err := provider.ExportKubeConfig(cfg.name, kubeconfigFile.Name(), cfg.internal); err != nil { @@ -540,6 +549,44 @@ func ensureKindCluster(cfg config) (clientcmd.ClientConfig, error) { return kubeconfig, nil } +func ensureKindClusterNetwork(ctx context.Context, provider *kind.Provider, clusterName, networkName string) error { + nodes, err := provider.ListNodes(clusterName) + if err != nil { + return errors.Wrapf(err, "failed to list nodes in kind cluster %q", clusterName) + } + if len(nodes) == 0 { + return errors.Errorf("kind cluster %q has no nodes to connect to Docker network %q", clusterName, networkName) + } + + networkID, found, err := docker.GetNetworkIDByName(ctx, networkName) + if err != nil { + return errors.Wrapf(err, "failed to look up Docker network %q", networkName) + } + if !found { + return errors.Errorf("missing Docker network %q", networkName) + } + + cli, err := docker.NewClient() + if err != nil { + return errors.Wrap(err, "failed to connect to Docker to ensure kind cluster network") + } + + for _, node := range nodes { + inspect, err := cli.ContainerInspect(ctx, node.String(), mobyclient.ContainerInspectOptions{}) + if err != nil { + return errors.Wrapf(err, "failed to inspect kind cluster node %q", node.String()) + } + if _, ok := inspect.Container.NetworkSettings.Networks[networkName]; ok { + continue + } + if _, err := cli.NetworkConnect(ctx, networkID, mobyclient.NetworkConnectOptions{Container: node.String()}); err != nil { + return errors.Wrapf(err, "failed to connect kind cluster node %q to Docker network %q", node.String(), networkName) + } + } + + return nil +} + func createNewKindCluster(provider *kind.Provider, c config, kubeconfigPath string) error { cfg := createKindClusterConfig() From d1ff8bd02086a572905084cea5f8f6eec5d35d1f Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 8 Oct 2026 14:06:26 +0200 Subject: [PATCH 13/20] fix(project): ensure existing registry container is connected to the specified network Signed-off-by: Nikita Z --- internal/project/controlplane/controlplane.go | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 3b5675b2..8a550d43 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -691,6 +691,32 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, storage docker.S return "", errors.Wrap(err, "failed to look up existing registry container") } if found { + if len(strings.TrimSpace(networkName)) == 0 { + networkName = "kind" + } + + networkID, found, err := docker.GetNetworkIDByName(ctx, networkName) + if err != nil { + return "", errors.Wrap(err, "failed to get docker network ID for existing registry") + } + if !found { + return "", errors.Errorf("missing docker network %q", networkName) + } + + cli, err := docker.NewClient() + if err != nil { + return "", errors.Wrap(err, "failed to connect to Docker to reconcile existing registry network") + } + inspect, err := cli.ContainerInspect(ctx, existing, mobyclient.ContainerInspectOptions{}) + if err != nil { + return "", errors.Wrap(err, "failed to inspect existing registry container") + } + if _, connected := inspect.Container.NetworkSettings.Networks[networkName]; !connected { + if _, err := cli.NetworkConnect(ctx, networkID, mobyclient.NetworkConnectOptions{Container: existing}); err != nil { + return "", errors.Wrapf(err, "failed to connect existing registry container to Docker network %q", networkName) + } + } + if err := docker.StartContainerByID(ctx, existing); err != nil { return "", errors.Wrap(err, "failed to start existing registry container") } From c6c3cfc20f1f3fb0f2b0c51c8cd2be28a87bcf21 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 8 Oct 2026 14:17:57 +0200 Subject: [PATCH 14/20] fix(project): reconcile existing registry storage backend Signed-off-by: Nikita Z --- internal/docker/storage.go | 14 ++++- internal/project/controlplane/controlplane.go | 63 ++++++++++++------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/internal/docker/storage.go b/internal/docker/storage.go index dfa233dd..747f249b 100644 --- a/internal/docker/storage.go +++ b/internal/docker/storage.go @@ -16,7 +16,6 @@ const ( StorageTypeVolume StorageType = "volume" ) -// var ValidStorageTypes = []string{ string(StorageTypeBindMount), string(StorageTypeVolume), @@ -35,6 +34,9 @@ func IsValidStorageType(storageType string) bool { // Storage configures container storage and synchronizes files to it. type Storage interface { + // Type identifies the storage backend. + Type() StorageType + // ContainerOptions returns the required docker start options for its storage type. ContainerOptions() []StartContainerOption @@ -64,6 +66,11 @@ func NewBindMountStorage(sourceDir, destDir string) *BindMountStorage { } } +// Type returns the bind-mount storage type. +func (s BindMountStorage) Type() StorageType { + return StorageTypeBindMount +} + // ContainerOptions returns the options for bind-mounting the source directory. func (s BindMountStorage) ContainerOptions() []StartContainerOption { return []StartContainerOption{ @@ -99,6 +106,11 @@ func NewVolumeStorage(sourceDir, destDir string, initFiles []byte) *VolumeStorag } } +// Type returns the volume storage type. +func (s VolumeStorage) Type() StorageType { + return StorageTypeVolume +} + // ContainerOptions returns the options for mounting and initializing the volume. func (s VolumeStorage) ContainerOptions() []StartContainerOption { return []StartContainerOption{ diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 8a550d43..debaba0d 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -684,6 +684,9 @@ func ensureCrossplane(restConfig *rest.Config, version, caConfigMap string, clus 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" + if len(strings.TrimSpace(networkName)) == 0 { + networkName = "kind" + } // Check for existing registry container. existing, found, err := docker.GetContainerIDByName(ctx, regName, true) @@ -691,18 +694,6 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, storage docker.S return "", errors.Wrap(err, "failed to look up existing registry container") } if found { - if len(strings.TrimSpace(networkName)) == 0 { - networkName = "kind" - } - - networkID, found, err := docker.GetNetworkIDByName(ctx, networkName) - if err != nil { - return "", errors.Wrap(err, "failed to get docker network ID for existing registry") - } - if !found { - return "", errors.Errorf("missing docker network %q", networkName) - } - cli, err := docker.NewClient() if err != nil { return "", errors.Wrap(err, "failed to connect to Docker to reconcile existing registry network") @@ -711,23 +702,51 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, storage docker.S if err != nil { return "", errors.Wrap(err, "failed to inspect existing registry container") } - if _, connected := inspect.Container.NetworkSettings.Networks[networkName]; !connected { - if _, err := cli.NetworkConnect(ctx, networkID, mobyclient.NetworkConnectOptions{Container: existing}); err != nil { - return "", errors.Wrapf(err, "failed to connect existing registry container to Docker network %q", networkName) + + mountType := "" + switch storage.Type() { + case docker.StorageTypeBindMount: + mountType = "bind" + case docker.StorageTypeVolume: + mountType = "volume" + default: + return "", errors.Errorf("unknown registry storage type %q", storage.Type()) + } + storageMatches := false + for _, mount := range inspect.Container.Mounts { + if mount.Destination == storage.DestDir() && string(mount.Type) == mountType { + storageMatches = true + break } } - if err := docker.StartContainerByID(ctx, existing); err != nil { - return "", errors.Wrap(err, "failed to start existing registry container") + if !storageMatches { + if err := teardownLocalRegistry(ctx, existing); err != nil { + return "", errors.Wrap(err, "failed to recreate registry with selected storage backend") + } + } else { + networkID, found, err := docker.GetNetworkIDByName(ctx, networkName) + if err != nil { + return "", errors.Wrap(err, "failed to get docker network ID for existing registry") + } + if !found { + return "", errors.Errorf("missing docker network %q", networkName) + } + + if _, connected := inspect.Container.NetworkSettings.Networks[networkName]; !connected { + if _, err := cli.NetworkConnect(ctx, networkID, mobyclient.NetworkConnectOptions{Container: existing}); err != nil { + return "", errors.Wrapf(err, "failed to connect existing registry container to Docker network %q", networkName) + } + } + + if err := docker.StartContainerByID(ctx, existing); err != nil { + return "", errors.Wrap(err, "failed to start existing registry container") + } + return existing, nil } - return existing, nil } // 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 28ed65df02c8af0fcef0812498acdb055f2a653f Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Thu, 8 Oct 2026 14:20:00 +0200 Subject: [PATCH 15/20] fix(project/storage): change StorageTypeBindMount to 'bind' Signed-off-by: Nikita Z --- apis/dev/v1alpha1/project_types.go | 2 +- internal/docker/storage.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go index 3d011a85..2a637c0b 100644 --- a/apis/dev/v1alpha1/project_types.go +++ b/apis/dev/v1alpha1/project_types.go @@ -158,7 +158,7 @@ type RegistryConfig struct { // StorageConfig is the configuration options for storage. type StorageConfig struct { // The type of storage to use. - // Options: "bindMount" (default), "volume". + // Options: "bind" (default), "volume". Type string `json:"type,omitempty"` } diff --git a/internal/docker/storage.go b/internal/docker/storage.go index 747f249b..da0dd04f 100644 --- a/internal/docker/storage.go +++ b/internal/docker/storage.go @@ -12,7 +12,7 @@ type StorageType string // Supported container storage backends. const ( - StorageTypeBindMount StorageType = "bindMount" + StorageTypeBindMount StorageType = "bind" StorageTypeVolume StorageType = "volume" ) From 4adff77948b72129ceda0db634ea14ced3697b2e Mon Sep 17 00:00:00 2001 From: Nikita Zakharov <54776184+nkzk@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:46:36 +0200 Subject: [PATCH 16/20] fix(project): restore/unset KIND_EXPERIMENTAL_DOCKER_NETWORK after use Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Nikita Zakharov <54776184+nkzk@users.noreply.github.com> --- internal/project/controlplane/controlplane.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index debaba0d..152daf73 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -600,6 +600,14 @@ func createNewKindCluster(provider *kind.Provider, c config, kubeconfigPath stri } if len(strings.TrimSpace(c.dockerNetwork)) > 0 { + prev, had := os.LookupEnv("KIND_EXPERIMENTAL_DOCKER_NETWORK") + defer func() { + if had { + _ = os.Setenv("KIND_EXPERIMENTAL_DOCKER_NETWORK", prev) + } else { + _ = os.Unsetenv("KIND_EXPERIMENTAL_DOCKER_NETWORK") + } + }() err := os.Setenv("KIND_EXPERIMENTAL_DOCKER_NETWORK", c.dockerNetwork) if err != nil { return errors.Wrap(err, "failed to set docker network") From e828feafa7cd95ae4b6e22818b6192259ecf9859 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Fri, 9 Oct 2026 11:36:58 +0200 Subject: [PATCH 17/20] refactor(project): configure container connectivity with attachContainers instead of docker-network Signed-off-by: Nikita Z --- apis/dev/v1alpha1/project_types.go | 8 +- apis/dev/v1alpha1/zz_generated.deepcopy.go | 101 ++++++++++++++++ cmd/crossplane/project/run.go | 19 ++- internal/docker/docker.go | 42 +++++++ internal/docker/storage.go | 18 ++- internal/project/controlplane/controlplane.go | 114 ++++-------------- 6 files changed, 185 insertions(+), 117 deletions(-) diff --git a/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go index 2a637c0b..b198af35 100644 --- a/apis/dev/v1alpha1/project_types.go +++ b/apis/dev/v1alpha1/project_types.go @@ -135,7 +135,7 @@ type RuntimeKind struct { // 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 configuration options Network RuntimeNetwork `json:"network,omitempty"` } @@ -147,7 +147,11 @@ type RuntimeConfig struct { // RuntimeNetwork is the spec for runtime network configuration. type RuntimeNetwork struct { - Name string `json:"name,omitempty"` + // AttachContainers lists existing containers to attach to the KinD network. + // Each entry accepts a Docker container name or ID. Containers already + // attached are left unchanged. Use this when running crossplane project + // in a container to give it access to the local development cluster. + AttachContainers []string `json:"attachContainers,omitempty"` } // RegistryConfig is the configuration options for the local registry. diff --git a/apis/dev/v1alpha1/zz_generated.deepcopy.go b/apis/dev/v1alpha1/zz_generated.deepcopy.go index dc5d5532..101e16a0 100644 --- a/apis/dev/v1alpha1/zz_generated.deepcopy.go +++ b/apis/dev/v1alpha1/zz_generated.deepcopy.go @@ -282,6 +282,7 @@ func (in *ProjectSpec) DeepCopyInto(out *ProjectSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + in.Runtime.DeepCopyInto(&out.Runtime) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectSpec. @@ -294,6 +295,106 @@ func (in *ProjectSpec) DeepCopy() *ProjectSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegistryConfig) DeepCopyInto(out *RegistryConfig) { + *out = *in + out.Storage = in.Storage +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryConfig. +func (in *RegistryConfig) DeepCopy() *RegistryConfig { + if in == nil { + return nil + } + out := new(RegistryConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Runtime) DeepCopyInto(out *Runtime) { + *out = *in + in.Kind.DeepCopyInto(&out.Kind) + out.Registry = in.Registry +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Runtime. +func (in *Runtime) DeepCopy() *Runtime { + if in == nil { + return nil + } + out := new(Runtime) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimeConfig) DeepCopyInto(out *RuntimeConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeConfig. +func (in *RuntimeConfig) DeepCopy() *RuntimeConfig { + if in == nil { + return nil + } + out := new(RuntimeConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimeKind) DeepCopyInto(out *RuntimeKind) { + *out = *in + out.Config = in.Config + in.Network.DeepCopyInto(&out.Network) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeKind. +func (in *RuntimeKind) DeepCopy() *RuntimeKind { + if in == nil { + return nil + } + out := new(RuntimeKind) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimeNetwork) DeepCopyInto(out *RuntimeNetwork) { + *out = *in + if in.AttachContainers != nil { + in, out := &in.AttachContainers, &out.AttachContainers + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeNetwork. +func (in *RuntimeNetwork) DeepCopy() *RuntimeNetwork { + if in == nil { + return nil + } + out := new(RuntimeNetwork) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StorageConfig) DeepCopyInto(out *StorageConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StorageConfig. +func (in *StorageConfig) DeepCopy() *StorageConfig { + if in == nil { + return nil + } + out := new(StorageConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *XpkgDependency) DeepCopyInto(out *XpkgDependency) { *out = *in diff --git a/cmd/crossplane/project/run.go b/cmd/crossplane/project/run.go index af2b830c..cd6d9d6c 100644 --- a/cmd/crossplane/project/run.go +++ b/cmd/crossplane/project/run.go @@ -72,15 +72,14 @@ 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:""` - Timeout time.Duration `default:"5m" help:"Max wait for project readiness."` - InitResources []string `help:"Resources to apply before installing." type:"path"` - ExtraResources []string `help:"Resources to apply after installing." type:"path"` + 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:""` + Timeout time.Duration `default:"5m" help:"Max wait for project readiness."` + InitResources []string `help:"Resources to apply before installing." type:"path"` + ExtraResources []string `help:"Resources to apply after installing." type:"path"` proj *devv1alpha1.Project projFS afero.Fs @@ -165,10 +164,6 @@ func (c *runCmd) resolveRunOptions() error { 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 { @@ -185,7 +180,7 @@ func (c *runCmd) resolveRunOptions() error { storageType := c.proj.Spec.Runtime.Registry.Storage.Type if len(strings.TrimSpace(storageType)) > 0 { if !docker.IsValidStorageType(storageType) { - return errors.Errorf("%q is an invalid storage type. Supported storage types: %v", storageType, docker.ValidStorageTypes) + return errors.Errorf("%q is an invalid storage type. Supported storage types: %v", storageType, docker.ValidStorageTypes()) } c.storageType = docker.StorageType(storageType) } @@ -274,7 +269,7 @@ 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.WithAttachContainers(c.proj.Spec.Runtime.Kind.Network.AttachContainers), controlplane.WithInternal(*c.Internal), controlplane.WithKindConfig(c.kindConfig), controlplane.WithStorageType(c.storageType), diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 8450e6c4..f8457b66 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -640,6 +640,48 @@ func TarDirectory(dir string) ([]byte, error) { return b, nil } +// ConnectContainerToNetwork connects a container to a docker-network. +// containerRef accepts a Docker container name or ID. +// If the container is already connected, returns nil. +func ConnectContainerToNetwork(ctx context.Context, containerRef, networkName string) error { + cli, err := NewClient() + if err != nil { + return err + } + + networkList, err := cli.NetworkList(ctx, client.NetworkListOptions{}) + if err != nil { + return errors.Wrap(err, "failed to list networks") + } + + networkID := "" + for _, network := range networkList.Items { + if network.Name == networkName { + networkID = network.ID + } + } + + if networkID == "" { + return errors.Errorf("failed to find network ID for %q. Does it exist?", networkName) + } + + inspectResult, err := cli.ContainerInspect(ctx, containerRef, client.ContainerInspectOptions{}) + if err != nil { + return errors.Wrapf(err, "failed to inspect container %q", containerRef) + } + + if _, connected := inspectResult.Container.NetworkSettings.Networks[networkName]; !connected { + _, err := cli.NetworkConnect(ctx, networkID, client.NetworkConnectOptions{ + Container: inspectResult.Container.ID, + }) + if err != nil { + return errors.Wrapf(err, "failed to connect container %q to %q", containerRef, networkName) + } + } + + return 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 index da0dd04f..e472112f 100644 --- a/internal/docker/storage.go +++ b/internal/docker/storage.go @@ -3,6 +3,7 @@ package docker import ( "context" "fmt" + "slices" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" ) @@ -16,20 +17,17 @@ const ( StorageTypeVolume StorageType = "volume" ) -var ValidStorageTypes = []string{ - string(StorageTypeBindMount), - string(StorageTypeVolume), +// ValidStorageTypes lists the supported container storage backend names. +func ValidStorageTypes() []string { + return []string{ + string(StorageTypeBindMount), + string(StorageTypeVolume), + } } // IsValidStorageType returns true if the supplied storageType string is one of the supported storagetypes. func IsValidStorageType(storageType string) bool { - for _, s := range ValidStorageTypes { - if s == storageType { - return true - } - } - - return false + return slices.Contains(ValidStorageTypes(), storageType) } // Storage configures container storage and synchronizes files to it. diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 152daf73..ca273b43 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -26,7 +26,6 @@ import ( "path" "path/filepath" "slices" - "strings" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/empty" @@ -226,7 +225,7 @@ type config struct { log logging.Logger kindConfig *v1alpha4.Cluster internal bool - dockerNetwork string + attachContainers []string storageType docker.StorageType } @@ -289,10 +288,11 @@ func WithInternal(internal bool) Option { } } -// WithDockerNetwork configures which docker network to start up the local development control plane in. -func WithDockerNetwork(network string) Option { +// WithAttachContainers sets the existing containers to attach to the KinD +// network. Each entry accepts a Docker container name or ID. +func WithAttachContainers(attachContainers []string) Option { return func(c *config) { - c.dockerNetwork = network + c.attachContainers = attachContainers } } @@ -331,12 +331,19 @@ 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, "internal", cfg.internal, "network", cfg.dockerNetwork) - kubeconfig, err := ensureKindCluster(ctx, *cfg) + cfg.log.Debug("Ensuring kind cluster", "name", cfg.name, "internal", cfg.internal) + kubeconfig, err := ensureKindCluster(*cfg) if err != nil { return nil, err } + for _, containerRef := range cfg.attachContainers { + err := docker.ConnectContainerToNetwork(ctx, containerRef, "kind") + if err != nil { + return nil, err + } + } + restConfig, err := kubeconfig.ClientConfig() if err != nil { return nil, errors.Wrap(err, "cannot get rest config") @@ -425,12 +432,8 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl } cfg.log.Debug("Ensuring local registry container") - networkName := cfg.dockerNetwork - if networkName == "" { - networkName = "kind" - } - cid, err := ensureLocalRegistry(ctx, cl, storage, regName, registryDir, certSecret, networkName) + cid, err := ensureLocalRegistry(ctx, cl, storage, regName, certSecret) if err != nil { return nil, err } @@ -503,7 +506,7 @@ func TeardownLocalDevControlPlane(ctx context.Context, name string, registryDir return nil } -func ensureKindCluster(ctx context.Context, cfg config) (clientcmd.ClientConfig, error) { +func ensureKindCluster(cfg config) (clientcmd.ClientConfig, error) { provider := kind.NewProvider() kubeconfigFile, err := os.CreateTemp("", "crossplane-*.kubeconfig") @@ -522,14 +525,6 @@ func ensureKindCluster(ctx context.Context, cfg config) (clientcmd.ClientConfig, if err := createNewKindCluster(provider, cfg, kubeconfigFile.Name()); err != nil { return nil, err } - } else { - networkName := cfg.dockerNetwork - if networkName == "" { - networkName = "kind" - } - if err := ensureKindClusterNetwork(ctx, provider, cfg.name, networkName); err != nil { - return nil, err - } } if err := provider.ExportKubeConfig(cfg.name, kubeconfigFile.Name(), cfg.internal); err != nil { @@ -549,44 +544,6 @@ func ensureKindCluster(ctx context.Context, cfg config) (clientcmd.ClientConfig, return kubeconfig, nil } -func ensureKindClusterNetwork(ctx context.Context, provider *kind.Provider, clusterName, networkName string) error { - nodes, err := provider.ListNodes(clusterName) - if err != nil { - return errors.Wrapf(err, "failed to list nodes in kind cluster %q", clusterName) - } - if len(nodes) == 0 { - return errors.Errorf("kind cluster %q has no nodes to connect to Docker network %q", clusterName, networkName) - } - - networkID, found, err := docker.GetNetworkIDByName(ctx, networkName) - if err != nil { - return errors.Wrapf(err, "failed to look up Docker network %q", networkName) - } - if !found { - return errors.Errorf("missing Docker network %q", networkName) - } - - cli, err := docker.NewClient() - if err != nil { - return errors.Wrap(err, "failed to connect to Docker to ensure kind cluster network") - } - - for _, node := range nodes { - inspect, err := cli.ContainerInspect(ctx, node.String(), mobyclient.ContainerInspectOptions{}) - if err != nil { - return errors.Wrapf(err, "failed to inspect kind cluster node %q", node.String()) - } - if _, ok := inspect.Container.NetworkSettings.Networks[networkName]; ok { - continue - } - if _, err := cli.NetworkConnect(ctx, networkID, mobyclient.NetworkConnectOptions{Container: node.String()}); err != nil { - return errors.Wrapf(err, "failed to connect kind cluster node %q to Docker network %q", node.String(), networkName) - } - } - - return nil -} - func createNewKindCluster(provider *kind.Provider, c config, kubeconfigPath string) error { cfg := createKindClusterConfig() @@ -599,21 +556,6 @@ func createNewKindCluster(provider *kind.Provider, c config, kubeconfigPath stri return errors.Wrap(err, "failed to marshal kind config") } - if len(strings.TrimSpace(c.dockerNetwork)) > 0 { - prev, had := os.LookupEnv("KIND_EXPERIMENTAL_DOCKER_NETWORK") - defer func() { - if had { - _ = os.Setenv("KIND_EXPERIMENTAL_DOCKER_NETWORK", prev) - } else { - _ = os.Unsetenv("KIND_EXPERIMENTAL_DOCKER_NETWORK") - } - }() - 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( c.name, kind.CreateWithRawConfig(cfgBytes), @@ -690,11 +632,11 @@ func ensureCrossplane(restConfig *rest.Config, version, caConfigMap string, clus return nil } -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" - if len(strings.TrimSpace(networkName)) == 0 { +func ensureLocalRegistry(ctx context.Context, cl client.Client, storage docker.Storage, regName string, certSecret *corev1.Secret) (string, error) { + const ( + regImage = "ghcr.io/olareg/olareg:edge" networkName = "kind" - } + ) // Check for existing registry container. existing, found, err := docker.GetContainerIDByName(ctx, regName, true) @@ -711,7 +653,7 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, storage docker.S return "", errors.Wrap(err, "failed to inspect existing registry container") } - mountType := "" + var mountType string switch storage.Type() { case docker.StorageTypeBindMount: mountType = "bind" @@ -733,20 +675,6 @@ func ensureLocalRegistry(ctx context.Context, cl client.Client, storage docker.S return "", errors.Wrap(err, "failed to recreate registry with selected storage backend") } } else { - networkID, found, err := docker.GetNetworkIDByName(ctx, networkName) - if err != nil { - return "", errors.Wrap(err, "failed to get docker network ID for existing registry") - } - if !found { - return "", errors.Errorf("missing docker network %q", networkName) - } - - if _, connected := inspect.Container.NetworkSettings.Networks[networkName]; !connected { - if _, err := cli.NetworkConnect(ctx, networkID, mobyclient.NetworkConnectOptions{Container: existing}); err != nil { - return "", errors.Wrapf(err, "failed to connect existing registry container to Docker network %q", networkName) - } - } - if err := docker.StartContainerByID(ctx, existing); err != nil { return "", errors.Wrap(err, "failed to start existing registry container") } From 710231adabbcca766caddfad3c32b2bf13295158 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Fri, 9 Oct 2026 15:18:34 +0200 Subject: [PATCH 18/20] fix(project): only include certs when initializing volume storage Signed-off-by: Nikita Z --- internal/docker/docker.go | 4 ++-- internal/project/controlplane/controlplane.go | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/docker/docker.go b/internal/docker/docker.go index f8457b66..19bed430 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -625,8 +625,8 @@ func TarFromContainer(ctx context.Context, cid, path string) ([]byte, error) { } // TarDirectory tars a directory. -func TarDirectory(dir string) ([]byte, error) { - rd, err := archive.TarWithOptions(dir, &archive.TarOptions{}) // archive.TarResourceRebaseOpts(base, base)) +func TarDirectory(dir string, tarOptions archive.TarOptions) ([]byte, error) { + rd, err := archive.TarWithOptions(dir, &tarOptions) // archive.TarResourceRebaseOpts(base, base)) if err != nil { return nil, errors.Wrapf(err, "failed to archive %s", dir) } diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index ca273b43..281740c9 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -30,6 +30,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/moby/go-archive" mobyclient "github.com/moby/moby/client" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" @@ -422,7 +423,9 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl case docker.StorageTypeBindMount: storage = docker.NewBindMountStorage(registryDir, destDir) case docker.StorageTypeVolume: - certTarball, err := docker.TarDirectory(registryDir) + certTarball, err := docker.TarDirectory(registryDir, archive.TarOptions{ + IncludeFiles: []string{certDirName}, + }) if err != nil { return nil, errors.Wrap(err, "failed to tar cert-directory") } From cfdb2d60a456b91aa79467da9bf2a2ba91794f54 Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Fri, 9 Oct 2026 15:26:50 +0200 Subject: [PATCH 19/20] fix(project): dont override node image if kind-config is provided Signed-off-by: Nikita Z --- internal/project/controlplane/controlplane.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 281740c9..536f4203 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -559,14 +559,17 @@ func createNewKindCluster(provider *kind.Provider, c config, kubeconfigPath stri return errors.Wrap(err, "failed to marshal kind config") } - if err := provider.Create( - c.name, + options := []kind.CreateOption{ kind.CreateWithRawConfig(cfgBytes), - kind.CreateWithNodeImage(defaults.Image), kind.CreateWithDisplayUsage(false), kind.CreateWithDisplaySalutation(false), kind.CreateWithKubeconfigPath(kubeconfigPath), - ); err != nil { + } + if c.kindConfig == nil { + options = append(options, kind.CreateWithNodeImage(defaults.Image)) + } + + if err := provider.Create(c.name, options...); err != nil { return errors.Wrap(err, "failed to create kind cluster") } From 814bc7ff606e964f4295b349bf78917ed47fe82f Mon Sep 17 00:00:00 2001 From: Nikita Z Date: Fri, 9 Oct 2026 15:35:31 +0200 Subject: [PATCH 20/20] fix(project): wrap error Signed-off-by: Nikita Z --- internal/project/controlplane/controlplane.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 536f4203..2bda3e58 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -388,7 +388,7 @@ func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControl // 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") + return nil, errors.Wrap(err, "failed to create cert directory") } // Check the persisted CA before replacing it. A registry initialized with a // different CA must be recreated so its storage and the cluster trust agree.