From 920910721fb7f6fa3df4a12dd2121fe1f4f929df Mon Sep 17 00:00:00 2001 From: Anton Sokolovskyi Date: Tue, 30 Jun 2026 13:17:11 +0200 Subject: [PATCH 1/2] Update PowerShell SHELLFLAGS to fix error on Windows --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 314f92ca..0a603989 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ ifeq ($(OS),Windows_NT) detected_OS := windows detected_arch := amd64 SHELL := pwsh.exe - .SHELLFLAGS := -Command + .SHELLFLAGS := -NoProfile -NonInteractive -Command repo_dir := $(shell Get-Location | Select-Object -ExpandProperty Path) mkdir := New-Item -ItemType Directory -Force -Path copy := Copy-Item -Force -Path From 0c6edc11a2749ce29117efb3ffd9751568577997 Mon Sep 17 00:00:00 2001 From: Anton Sokolovskyi Date: Tue, 30 Jun 2026 18:09:12 +0200 Subject: [PATCH 2/2] Add wslc container runtime support with content-addressed derived-image cache Introduces the wslc CLI orchestrator and wires it through the container/network orchestrator abstractions and runtime flag. Adds a content-addressed derived-image cache that skips rebuilds when the base image content and image layers are unchanged, keyed on the base image's content ID rather than its (possibly mutable) tag. --- controllers/container_controller.go | 269 ++- .../container_image_layer_cache_test.go | 241 +++ internal/containers/container_orchestrator.go | 20 + internal/containers/containers_common.go | 58 + internal/containers/containers_common_test.go | 111 ++ .../containers/flags/container_runtime.go | 3 +- internal/containers/network_orchestrator.go | 6 + internal/containers/runtimes/runtime.go | 2 + internal/docker/cli_orchestrator.go | 8 + internal/podman/cli_orchestrator.go | 8 + .../ctrlutil/test_container_orchestrator.go | 8 + internal/wslc/cli_orchestrator.go | 1612 +++++++++++++++++ internal/wslc/cli_orchestrator_test.go | 621 +++++++ 13 files changed, 2921 insertions(+), 46 deletions(-) create mode 100644 controllers/container_image_layer_cache_test.go create mode 100644 internal/containers/containers_common_test.go create mode 100644 internal/wslc/cli_orchestrator.go create mode 100644 internal/wslc/cli_orchestrator_test.go diff --git a/controllers/container_controller.go b/controllers/container_controller.go index b1fa4bb7..77d28f11 100644 --- a/controllers/container_controller.go +++ b/controllers/container_controller.go @@ -8,6 +8,8 @@ package controllers import ( "context" "crypto/sha256" + "encoding/base64" + "encoding/hex" "errors" "fmt" "os" @@ -16,6 +18,7 @@ import ( "reflect" "regexp" "runtime" + "sort" "strings" "sync" "time" @@ -77,6 +80,14 @@ var ( containerFinalizer string = fmt.Sprintf("%s/container-reconciler", apiv1.GroupVersion.Group) containerKind = apiv1.GroupVersion.WithKind(reflect.TypeOf(apiv1.Container{}).Name()) + // bakedLayerModTime is a fixed, deterministic modification time applied to files baked into a + // derived image layer. Using a constant (rather than time.Now()) keeps the resulting tar bytes — + // and therefore the layer digest, which is the cache key — stable across runs when the file contents + // are unchanged, which is what lets the content-addressed derived-image cache reuse a built image. + // Certificate identity needs no special handling: the certificate bytes are part of the hashed tar, + // so rotating a certificate changes the digest and correctly misses the cache. + bakedLayerModTime = time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC) + containerStateInitializers = map[apiv1.ContainerState]containerStateInitializerFunc{ apiv1.ContainerStateEmpty: handleNewContainer, apiv1.ContainerStatePending: handleNewContainer, @@ -95,6 +106,11 @@ var ( winNamedPipeRegex = regexp.MustCompile(`^(\\\\.pipe\\|//.pipe/)`) ) +// errContainerNetworksNotReady is a retryable error returned during startup when a runtime that +// attaches networks at creation time (see ContainerOrchestrator.NetworksAttachedAtCreation) must +// wait for the referenced ContainerNetwork objects to become ready before creating the container. +var errContainerNetworksNotReady = errors.New("referenced container networks are not ready") + type ContainerReconcilerConfig struct { MaxParallelContainerStarts uint8 ContainerStartupTimeoutOverride time.Duration @@ -600,8 +616,9 @@ func ensureContainerStartingState( r.scheduleContainerCreation(ctx, container, rcd, log, startupWithNoDelay) change |= statusChanged - case templating.IsTransientTemplateError(rcd.startupError), errors.Is(rcd.startupError, statestore.ErrResourceLeaseHeld): - // Retry startup after a transient error or while another DCP instance is creating the persistent container. + case templating.IsTransientTemplateError(rcd.startupError), errors.Is(rcd.startupError, statestore.ErrResourceLeaseHeld), errors.Is(rcd.startupError, errContainerNetworksNotReady): + // Retry startup after a transient error, while another DCP instance is creating the persistent + // container, or while waiting for referenced networks to become ready (create-time network attach). rcd.startupError = nil rcd.startAttemptFinishedAt = metav1.MicroTime{} @@ -1229,21 +1246,46 @@ func (r *ContainerReconciler) startContainerWithOrchestrator(container *apiv1.Co log.V(1).Info("Starting container", "Image", container.SpecifiedImageNameOrDefault()) - // Determine the default bridge network name used by the active orchestrator + // Determine how the container should be attached to networks at creation time. + // Most runtimes create the container on the default bridge network and connect custom + // networks afterward. Runtimes that can only attach networks at creation time (see + // NetworksAttachedAtCreation, e.g. wslc) are created already attached to the resolved + // custom networks and aliases; resolution waits until those networks are ready. defaultNetwork := "" + var creationNetworks []containers.CreationNetwork if rcd.runSpec.Networks != nil { - // See comment below why we create the container with default network explicitly enabled here. - defaultNetwork = r.orchestrator.DefaultNetworkName() + if r.orchestrator.NetworksAttachedAtCreation() { + resolved, resolveErr := r.resolveCreationNetworks(startupCtx, container, rcd, log) + if resolveErr != nil { + return resolveErr + } + creationNetworks = resolved + } else { + // See comment below why we create the container with default network explicitly enabled here. + defaultNetwork = r.orchestrator.DefaultNetworkName() + } } // Add labels to the container for lifecycle management and other metadata - lifecycleKey, hashErr := r.addContainerCreationLabels(container, rcd, log) + _, hashErr := r.addContainerCreationLabels(container, rcd, log) if hashErr != nil { return hashErr } + // Runtimes that cannot copy files into a created-but-not-running container (e.g. wslc) + // bake the configured create-files and PEM certificates into a derived image as additional + // layers, so the files are present in the container filesystem before the entrypoint runs. + var createFilesLayers []apiv1.ImageLayer + if r.orchestrator.CreateFilesRequiresRunningContainer() { + bakedLayers, bakeErr := r.bakeCreateFilesImageLayers(startupCtx, rcd, log) + if bakeErr != nil { + return bakeErr + } + createFilesLayers = bakedLayers + } + // Apply any image layers that are specified in the container spec, and get the effective image to use for container creation - effectiveImage, imageLayersErr := r.applyContainerImageLayers(startupCtx, rcd, lifecycleKey, log) + effectiveImage, imageLayersErr := r.applyContainerImageLayers(startupCtx, rcd, createFilesLayers, log) if imageLayersErr != nil { return imageLayersErr } @@ -1267,6 +1309,7 @@ func (r *ContainerReconciler) startContainerWithOrchestrator(container *apiv1.Co ContainerSpec: runSpecForCreation, Name: containerName, Network: defaultNetwork, + CreationNetworks: creationNetworks, StreamCommandOptions: streamOptions, } inspected, createErr := createContainer(startupCtx, r.orchestrator, creationOptions) @@ -1286,17 +1329,15 @@ func (r *ContainerReconciler) startContainerWithOrchestrator(container *apiv1.Co r.runContainerLifecycleMonitor(rcd, log) } - // Copy any general files specified in the container spec to the container's filesystem - fileModTime := time.Now() - copyFilesErr := r.copyContainerCreateFiles(startupCtx, rcd, inspected, fileModTime, log) - if copyFilesErr != nil { - return copyFilesErr - } - - // Copy any PEM certificates specified in the container spec to the container's filesystem - copyCertsErr := r.copyContainerPemCertificates(startupCtx, rcd, inspected, fileModTime, log) - if copyCertsErr != nil { - return copyCertsErr + // Copy any general files and PEM certificates specified in the container spec into the + // container's filesystem. Most runtimes copy into the created, not-yet-started container. + // Runtimes that can only write into a running container (e.g. wslc) cannot do this; they + // instead bake the files into the image as layers at creation time (see + // bakeCreateFilesImageLayers above), so the copy here is skipped for them. + if !r.orchestrator.CreateFilesRequiresRunningContainer() { + if copyErr := r.copyContainerCreateFilesAndCertificates(startupCtx, rcd, inspected.Id, time.Now(), r.orchestrator.CreateFiles, log); copyErr != nil { + return copyErr + } } // Finish the container startup process, including any finalization steps @@ -1311,7 +1352,7 @@ func (r *ContainerReconciler) startContainerWithOrchestrator(container *apiv1.Co return nil }() - retryableErr := templating.IsTransientTemplateError(err) || errors.Is(err, statestore.ErrResourceLeaseHeld) + retryableErr := templating.IsTransientTemplateError(err) || errors.Is(err, statestore.ErrResourceLeaseHeld) || errors.Is(err, errContainerNetworksNotReady) if !retryableErr && !errors.Is(err, statestore.ErrResourceLeaseNotHeld) { releaseErr := r.releasePersistentContainerResourceLease(context.WithoutCancel(startupCtx), container, log, false) err = errors.Join(err, releaseErr) @@ -1494,14 +1535,21 @@ func (r *ContainerReconciler) addContainerCreationLabels(container *apiv1.Contai } // applyContainerImageLayers builds a derived image when image layers are configured and returns the image to create. -func (r *ContainerReconciler) applyContainerImageLayers(ctx context.Context, rcd *runningContainerData, lifecycleKey string, log logr.Logger) (string, error) { +func (r *ContainerReconciler) applyContainerImageLayers(ctx context.Context, rcd *runningContainerData, extraLayers []apiv1.ImageLayer, log logr.Logger) (string, error) { effectiveImage := rcd.runSpec.Image - if len(rcd.runSpec.ImageLayers) == 0 { + + // Combine the layers declared in the run spec with any layers baked at creation time (e.g. wslc + // create-files). Do not mutate rcd.runSpec.ImageLayers: the container creation block may retry, and + // mutating the spec would append the extra layers more than once. + layers := make([]apiv1.ImageLayer, 0, len(rcd.runSpec.ImageLayers)+len(extraLayers)) + layers = append(layers, rcd.runSpec.ImageLayers...) + layers = append(layers, extraLayers...) + if len(layers) == 0 { return effectiveImage, nil } - digests := make([]string, len(rcd.runSpec.ImageLayers)) - for i, layer := range rcd.runSpec.ImageLayers { + digests := make([]string, len(layers)) + for i, layer := range layers { digests[i] = layer.Digest } rcd.runSpec.Labels = append(rcd.runSpec.Labels, apiv1.ContainerLabel{ @@ -1520,24 +1568,40 @@ func (r *ContainerReconciler) applyContainerImageLayers(ctx context.Context, rcd return "", ensureErr } - // Derive a tag by replacing the base image's tag/digest with the lifecycle key. - // The image ref may be name:tag or name@sha256:digest — we need just the name part. + // Derive a deterministic, content-addressed tag for the derived image. The tag keeps the base + // image's repository name and original tag as a prefix, then appends a digest over the resolved + // base image ID plus the applied layer digests, so identical inputs map to the same tag on every + // run while staying easy to correlate with the base image (e.g. redis:7.2 -> redis:7.2-dcp-). + // The image ref may be name:tag, name@sha256:digest, or name:tag@sha256:digest. baseRepo := rcd.runSpec.Image + baseTag := "" if atidx := strings.Index(baseRepo, "@"); atidx != -1 { - baseRepo = baseRepo[:atidx] - } else if colonidx := strings.LastIndex(baseRepo, ":"); colonidx != -1 { - // Only strip at colon if it's a tag separator, not part of a port/registry. - // A tag separator colon comes after the last slash (or is the only colon). - slashIdx := strings.LastIndex(baseRepo, "/") - if colonidx > slashIdx { - baseRepo = baseRepo[:colonidx] - } + baseRepo = baseRepo[:atidx] // drop any digest portion before parsing the tag + } + // A tag separator colon comes after the last slash (otherwise the colon is part of a registry port). + if colonidx := strings.LastIndex(baseRepo, ":"); colonidx > strings.LastIndex(baseRepo, "/") { + baseTag = baseRepo[colonidx+1:] + baseRepo = baseRepo[:colonidx] } - sanitizedKey := strings.ReplaceAll(lifecycleKey, ":", "-") - derivedTag := fmt.Sprintf("%s:dcp-%s", baseRepo, sanitizedKey) + contentDigest := sha256.Sum256([]byte(baseImageInspected.Id + "\n" + strings.Join(digests, "\n"))) + derivedTagSuffix := fmt.Sprintf("dcp-%x", contentDigest) + if baseTag != "" { + derivedTagSuffix = baseTag + "-" + derivedTagSuffix + } + derivedTag := fmt.Sprintf("%s:%s", baseRepo, derivedTagSuffix) + + // Reuse a previously built derived image when one already exists for this exact content. Building + // the derived image is expensive (and on some runtimes mounts a build context that counts against a + // session-wide volume limit), so skipping it when the inputs are unchanged avoids redundant work + // across runs. A miss here is expected (first run, or inputs changed) and falls through to building. + if existing, inspectErr := r.orchestrator.InspectImages(ctx, containers.InspectImagesOptions{Images: []string{derivedTag}}); inspectErr == nil && len(existing) > 0 { + log.V(1).Info("Reusing cached derived image for image layers", "DerivedImage", derivedTag) + return derivedTag, nil + } + applyLayersOptions := containers.ApplyImageLayersOptions{ BaseImage: *baseImageInspected, - Layers: rcd.runSpec.ImageLayers, + Layers: layers, Tag: derivedTag, } derivedImage, applyErr := r.orchestrator.ApplyImageLayers(ctx, applyLayersOptions) @@ -1622,12 +1686,18 @@ func (r *ContainerReconciler) releasePersistentContainerResourceLease( return nil } +// createFilesSink consumes a single CreateFiles operation. The default sink writes the files into a +// container via the orchestrator; an alternative sink (see bakeCreateFilesImageLayers) turns each +// operation into an image layer instead. +type createFilesSink func(ctx context.Context, options containers.CreateFilesOptions) error + // copyContainerCreateFiles copies configured file entries into the created container before it starts. func (r *ContainerReconciler) copyContainerCreateFiles( ctx context.Context, rcd *runningContainerData, - inspected *containers.InspectedContainer, + containerID string, fileModTime time.Time, + sink createFilesSink, log logr.Logger, ) error { for _, createFileRequest := range rcd.runSpec.CreateFiles { @@ -1637,7 +1707,7 @@ func (r *ContainerReconciler) copyContainerCreateFiles( } createFilesOptions := containers.CreateFilesOptions{ - Container: inspected.Id, + Container: containerID, Entries: createFileRequest.Entries, Destination: createFileRequest.Destination, DefaultOwner: createFileRequest.DefaultOwner, @@ -1646,7 +1716,7 @@ func (r *ContainerReconciler) copyContainerCreateFiles( ModTime: fileModTime, } - copyErr := r.orchestrator.CreateFiles(ctx, createFilesOptions) + copyErr := sink(ctx, createFilesOptions) if copyErr != nil { log.Error(copyErr, "Could not copy files to the container", "Destination", createFileRequest.Destination) return copyErr @@ -1658,12 +1728,63 @@ func (r *ContainerReconciler) copyContainerCreateFiles( return nil } +// copyContainerCreateFilesAndCertificates copies configured file entries and PEM certificates into the container. +func (r *ContainerReconciler) copyContainerCreateFilesAndCertificates( + ctx context.Context, + rcd *runningContainerData, + containerID string, + fileModTime time.Time, + sink createFilesSink, + log logr.Logger, +) error { + if copyFilesErr := r.copyContainerCreateFiles(ctx, rcd, containerID, fileModTime, sink, log); copyFilesErr != nil { + return copyFilesErr + } + + return r.copyContainerPemCertificates(ctx, rcd, containerID, fileModTime, sink, log) +} + +// bakeCreateFilesImageLayers converts the container's configured create-files and PEM certificates into +// image layers (tar archives). Runtimes that cannot copy files into a created-but-not-running container +// (e.g. wslc) bake these layers into a derived image at creation time, so injected files — including TLS +// certificates an entrypoint reads at startup — are present before the container starts. +func (r *ContainerReconciler) bakeCreateFilesImageLayers(ctx context.Context, rcd *runningContainerData, log logr.Logger) ([]apiv1.ImageLayer, error) { + layers := []apiv1.ImageLayer{} + sink := func(_ context.Context, options containers.CreateFilesOptions) error { + tarBytes, buildErr := containers.BuildCreateFilesTar(options, log) + if buildErr != nil { + return buildErr + } + if tarBytes == nil { + return nil + } + + digest := sha256.Sum256(tarBytes) + layers = append(layers, apiv1.ImageLayer{ + Digest: hex.EncodeToString(digest[:]), + RawContents: base64.StdEncoding.EncodeToString(tarBytes), + }) + return nil + } + + // The container does not exist yet, so the container ID passed to the sink is empty; the sink + // ignores it and turns each CreateFiles operation into an image layer. A fixed, deterministic mod + // time is used so unchanged contents produce an identical layer digest on every run, allowing the + // content-addressed derived-image cache to reuse a previously built image. + if buildErr := r.copyContainerCreateFilesAndCertificates(ctx, rcd, "", bakedLayerModTime, sink, log); buildErr != nil { + return nil, buildErr + } + + return layers, nil +} + // copyContainerPemCertificates installs configured PEM certificates and optional bundle overwrites into the container. func (r *ContainerReconciler) copyContainerPemCertificates( ctx context.Context, rcd *runningContainerData, - inspected *containers.InspectedContainer, + containerID string, fileModTime time.Time, + sink createFilesSink, log logr.Logger, ) error { if rcd.runSpec.PemCertificates == nil { @@ -1711,7 +1832,7 @@ func (r *ContainerReconciler) copyContainerPemCertificates( } createFilesOptions := containers.CreateFilesOptions{ - Container: inspected.Id, + Container: containerID, Destination: rcd.runSpec.PemCertificates.Destination, Entries: []apiv1.FileSystemEntry{ { @@ -1728,7 +1849,7 @@ func (r *ContainerReconciler) copyContainerPemCertificates( ModTime: fileModTime, } - copyErr := r.orchestrator.CreateFiles(ctx, createFilesOptions) + copyErr := sink(ctx, createFilesOptions) if copyErr != nil { log.Error(copyErr, "Could not copy certificates to the container", "Destination", createFilesOptions.Destination) return copyErr @@ -1738,9 +1859,16 @@ func (r *ContainerReconciler) copyContainerPemCertificates( return path.Dir(bundlePath) }) - for bundleDir, files := range overwritePaths { + // Iterate the overwrite directories in a stable order. When these operations are baked into image + // layers, the layer sequence determines the content-addressed cache key, so a deterministic order is + // required for the derived-image cache to reuse a previously built image. + bundleDirs := maps.Keys(overwritePaths) + sort.Strings(bundleDirs) + + for _, bundleDir := range bundleDirs { + files := overwritePaths[bundleDir] bundleCreateFilesOptions := containers.CreateFilesOptions{ - Container: inspected.Id, + Container: containerID, Destination: bundleDir, Entries: slices.Map[apiv1.FileSystemEntry](files, func(file string) apiv1.FileSystemEntry { return apiv1.FileSystemEntry{ @@ -1753,7 +1881,7 @@ func (r *ContainerReconciler) copyContainerPemCertificates( ModTime: fileModTime, } - bundleCopyErr := r.orchestrator.CreateFiles(ctx, bundleCreateFilesOptions) + bundleCopyErr := sink(ctx, bundleCreateFilesOptions) if bundleCopyErr != nil { if rcd.runSpec.PemCertificates.ContinueOnError { log.Info("Could not overwrite certificate bundle in the container, continuing...", "Destination", bundleDir, "Error", bundleCopyErr.Error()) @@ -1810,6 +1938,15 @@ func (r *ContainerReconciler) finishCreatedContainerStartup( // // During next reconciliation loop we create ContainerNetworkConnection objects and start the container resource. // The Network controller takes care of connecting the container resource to requested networks. + // + // Runtimes that attach networks at creation time (see NetworksAttachedAtCreation) are already + // connected to their requested networks, so there is nothing to detach. The container is left in + // the "starting" state so the normal network-connection flow creates ContainerNetworkConnection + // objects and then starts it. + if r.orchestrator.NetworksAttachedAtCreation() { + return nil + } + for i := range inspected.Networks { networkID := inspected.Networks[i].Id disconnectErr := disconnectNetwork(ctx, r.orchestrator, containers.DisconnectNetworkOptions{Network: networkID, Container: string(rcd.containerID), Force: true}) @@ -2038,6 +2175,48 @@ func (r *ContainerReconciler) enableEndpointsAndHealthProbes( // NETWORKING SUPPORT METHODS +// resolveCreationNetworks resolves the container's requested networks to runtime network names and +// aliases for runtimes that attach networks at creation time (see NetworksAttachedAtCreation). +// It returns errContainerNetworksNotReady (a retryable startup error) if any referenced +// ContainerNetwork object does not yet exist or has not been assigned a runtime network name. +func (r *ContainerReconciler) resolveCreationNetworks( + ctx context.Context, + container *apiv1.Container, + rcd *runningContainerData, + log logr.Logger, +) ([]containers.CreationNetwork, error) { + if rcd.runSpec.Networks == nil { + return nil, nil + } + + var networks apiv1.ContainerNetworkList + if err := r.List(ctx, &networks, ctrl_client.InNamespace(container.GetNamespace())); err != nil { + log.Error(err, "Failed to list ContainerNetwork objects") + return nil, err + } + + creationNetworks := make([]containers.CreationNetwork, 0, len(*rcd.runSpec.Networks)) + for i := range *rcd.runSpec.Networks { + requested := (*rcd.runSpec.Networks)[i] + namespacedName := commonapi.AsNamespacedName(requested.Name, container.GetNamespace()) + + index := slices.IndexFunc(networks.Items, func(n apiv1.ContainerNetwork) bool { + return n.NamespacedName() == namespacedName + }) + if index < 0 || networks.Items[index].Status.NetworkName == "" { + log.V(1).Info("Referenced container network is not ready yet, deferring container creation...", "Network", namespacedName.String()) + return nil, errContainerNetworksNotReady + } + + creationNetworks = append(creationNetworks, containers.CreationNetwork{ + Name: networks.Items[index].Status.NetworkName, + Aliases: requested.Aliases, + }) + } + + return creationNetworks, nil +} + // Creates initial set of ContainerNetworkConnection objects for this Container, // and if all connections are satisfied, starts the container. // Returns the inspected container data (if the container has been started successfully), and an error, if any. diff --git a/controllers/container_image_layer_cache_test.go b/controllers/container_image_layer_cache_test.go new file mode 100644 index 00000000..ef5a5841 --- /dev/null +++ b/controllers/container_image_layer_cache_test.go @@ -0,0 +1,241 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package controllers + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/require" + + apiv1 "github.com/microsoft/dcp/api/v1" + "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/pkg/testutil" +) + +// imageLayerCacheTestOrchestrator is a minimal ContainerOrchestrator used to exercise the +// derived-image caching behavior in applyContainerImageLayers. Only the image-inspection, +// pull, and apply-layers methods are implemented; any other method panics (nil embedded +// interface) which keeps the test honest about the code path under test. +type imageLayerCacheTestOrchestrator struct { + containers.ContainerOrchestrator + + mu sync.Mutex + images map[string]containers.InspectedImage // ref/tag -> inspected image + applyCalls []string // derived tags passed to ApplyImageLayers, in order + pullCalls int +} + +func newImageLayerCacheTestOrchestrator() *imageLayerCacheTestOrchestrator { + return &imageLayerCacheTestOrchestrator{images: map[string]containers.InspectedImage{}} +} + +func (o *imageLayerCacheTestOrchestrator) addImage(ref string, img containers.InspectedImage) { + o.mu.Lock() + defer o.mu.Unlock() + o.images[ref] = img +} + +func (o *imageLayerCacheTestOrchestrator) InspectImages(_ context.Context, options containers.InspectImagesOptions) ([]containers.InspectedImage, error) { + o.mu.Lock() + defer o.mu.Unlock() + + var result []containers.InspectedImage + var err error + for _, ref := range options.Images { + if img, ok := o.images[ref]; ok { + result = append(result, img) + } else { + err = errors.Join(err, containers.ErrNotFound) + } + } + return result, err +} + +func (o *imageLayerCacheTestOrchestrator) PullImage(_ context.Context, options containers.PullImageOptions) (string, error) { + o.mu.Lock() + defer o.mu.Unlock() + + o.pullCalls++ + img := containers.InspectedImage{Id: "pulled-" + options.Image, Tags: []string{options.Image}} + o.images[options.Image] = img + return img.Id, nil +} + +func (o *imageLayerCacheTestOrchestrator) ApplyImageLayers(_ context.Context, options containers.ApplyImageLayersOptions) (string, error) { + o.mu.Lock() + defer o.mu.Unlock() + + o.applyCalls = append(o.applyCalls, options.Tag) + // Simulate the build registering the derived image so subsequent inspections hit the cache. + o.images[options.Tag] = containers.InspectedImage{Id: "built-" + options.Tag, Tags: []string{options.Tag}} + return options.Tag, nil +} + +func newImageLayerRCD(image string, layers ...apiv1.ImageLayer) *runningContainerData { + return &runningContainerData{ + runSpec: &apiv1.ContainerSpec{ + Image: image, + ImageLayers: layers, + }, + } +} + +// Verifies that a second apply with identical base image and layers reuses the cached derived +// image instead of rebuilding it. +func TestApplyContainerImageLayers_ReusesCachedDerivedImage(t *testing.T) { + t.Parallel() + + ctx, cancel := testutil.GetTestContext(t, 30*time.Second) + defer cancel() + + orch := newImageLayerCacheTestOrchestrator() + orch.addImage("redis:7.2", containers.InspectedImage{Id: "base-redis-id", Tags: []string{"redis:7.2"}}) + + r := &ContainerReconciler{orchestrator: orch} + layer := apiv1.ImageLayer{Digest: "layer-digest-1", RawContents: "dGFy"} + + firstImage, firstErr := r.applyContainerImageLayers(ctx, newImageLayerRCD("redis:7.2", layer), nil, logr.Discard()) + require.NoError(t, firstErr) + require.NotEqual(t, "redis:7.2", firstImage, "a derived image should be produced") + require.Regexp(t, `^redis:7\.2-dcp-[0-9a-f]{64}$`, firstImage, "the derived tag should preserve the original tag as a prefix") + + secondImage, secondErr := r.applyContainerImageLayers(ctx, newImageLayerRCD("redis:7.2", layer), nil, logr.Discard()) + require.NoError(t, secondErr) + + require.Equal(t, firstImage, secondImage, "identical inputs must map to the same derived tag") + require.Len(t, orch.applyCalls, 1, "the derived image should be built only once and reused on the second call") +} + +// Verifies that changing layer content produces a different derived tag and triggers a rebuild. +func TestApplyContainerImageLayers_RebuildsWhenLayerContentChanges(t *testing.T) { + t.Parallel() + + ctx, cancel := testutil.GetTestContext(t, 30*time.Second) + defer cancel() + + orch := newImageLayerCacheTestOrchestrator() + orch.addImage("redis:7.2", containers.InspectedImage{Id: "base-redis-id", Tags: []string{"redis:7.2"}}) + + r := &ContainerReconciler{orchestrator: orch} + + firstImage, firstErr := r.applyContainerImageLayers( + ctx, newImageLayerRCD("redis:7.2", apiv1.ImageLayer{Digest: "layer-digest-1"}), nil, logr.Discard()) + require.NoError(t, firstErr) + + secondImage, secondErr := r.applyContainerImageLayers( + ctx, newImageLayerRCD("redis:7.2", apiv1.ImageLayer{Digest: "layer-digest-2"}), nil, logr.Discard()) + require.NoError(t, secondErr) + + require.NotEqual(t, firstImage, secondImage, "different layer content must map to different derived tags") + require.Len(t, orch.applyCalls, 2, "a content change must trigger a rebuild") +} + +// Verifies that the same layer applied on top of different base image content yields different +// derived tags, confirming the base image ID is folded into the cache key. +func TestApplyContainerImageLayers_BaseImageIdAffectsDerivedTag(t *testing.T) { + t.Parallel() + + ctx, cancel := testutil.GetTestContext(t, 30*time.Second) + defer cancel() + + layer := apiv1.ImageLayer{Digest: "shared-layer-digest"} + + orchA := newImageLayerCacheTestOrchestrator() + orchA.addImage("redis:7.2", containers.InspectedImage{Id: "base-id-A", Tags: []string{"redis:7.2"}}) + rA := &ContainerReconciler{orchestrator: orchA} + imageA, errA := rA.applyContainerImageLayers(ctx, newImageLayerRCD("redis:7.2", layer), nil, logr.Discard()) + require.NoError(t, errA) + + orchB := newImageLayerCacheTestOrchestrator() + orchB.addImage("redis:7.2", containers.InspectedImage{Id: "base-id-B", Tags: []string{"redis:7.2"}}) + rB := &ContainerReconciler{orchestrator: orchB} + imageB, errB := rB.applyContainerImageLayers(ctx, newImageLayerRCD("redis:7.2", layer), nil, logr.Discard()) + require.NoError(t, errB) + + require.NotEqual(t, imageA, imageB, "a change in the base image content must change the derived tag") +} + +// Verifies that layers baked at creation time (passed as extraLayers) are folded into the cache +// key, and that the cache hits across calls when the baked content is unchanged. +func TestApplyContainerImageLayers_ExtraLayersParticipateInCacheKey(t *testing.T) { + t.Parallel() + + ctx, cancel := testutil.GetTestContext(t, 30*time.Second) + defer cancel() + + orch := newImageLayerCacheTestOrchestrator() + orch.addImage("postgres:16", containers.InspectedImage{Id: "base-pg-id", Tags: []string{"postgres:16"}}) + + r := &ContainerReconciler{orchestrator: orch} + extra := []apiv1.ImageLayer{{Digest: "cert-layer-digest"}} + + firstImage, firstErr := r.applyContainerImageLayers(ctx, newImageLayerRCD("postgres:16"), extra, logr.Discard()) + require.NoError(t, firstErr) + + secondImage, secondErr := r.applyContainerImageLayers(ctx, newImageLayerRCD("postgres:16"), extra, logr.Discard()) + require.NoError(t, secondErr) + + require.Equal(t, firstImage, secondImage) + require.Len(t, orch.applyCalls, 1, "unchanged baked layers should hit the cache on the second call") + + differentExtra := []apiv1.ImageLayer{{Digest: "cert-layer-digest-rotated"}} + thirdImage, thirdErr := r.applyContainerImageLayers(ctx, newImageLayerRCD("postgres:16"), differentExtra, logr.Discard()) + require.NoError(t, thirdErr) + + require.NotEqual(t, firstImage, thirdImage, "rotated baked content must produce a new derived tag") + require.Len(t, orch.applyCalls, 2) +} + +// Verifies the derived tag format preserves the base repository and original tag as a prefix across +// the supported image reference forms (plain, registry-with-port, and digest-only). +func TestApplyContainerImageLayers_DerivedTagFormat(t *testing.T) { + t.Parallel() + + ctx, cancel := testutil.GetTestContext(t, 30*time.Second) + defer cancel() + + layer := apiv1.ImageLayer{Digest: "layer-digest"} + + cases := []struct { + name string + image string + tagDigest string // digest portion to register the base image under, if any + wantPattern string + }{ + { + name: "plain repository and tag", + image: "redis:7.2", + wantPattern: `^redis:7\.2-dcp-[0-9a-f]{64}$`, + }, + { + name: "registry with port and tag", + image: "localhost:5000/redis:7.2", + wantPattern: `^localhost:5000/redis:7\.2-dcp-[0-9a-f]{64}$`, + }, + { + name: "digest-only reference has no original tag to preserve", + image: "redis@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + wantPattern: `^redis:dcp-[0-9a-f]{64}$`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + orch := newImageLayerCacheTestOrchestrator() + orch.addImage(tc.image, containers.InspectedImage{Id: "base-id-" + tc.name, Tags: []string{tc.image}}) + r := &ContainerReconciler{orchestrator: orch} + + derived, err := r.applyContainerImageLayers(ctx, newImageLayerRCD(tc.image, layer), nil, logr.Discard()) + require.NoError(t, err) + require.Regexp(t, tc.wantPattern, derived) + }) + } +} diff --git a/internal/containers/container_orchestrator.go b/internal/containers/container_orchestrator.go index 98df2099..b9839570 100644 --- a/internal/containers/container_orchestrator.go +++ b/internal/containers/container_orchestrator.go @@ -300,6 +300,11 @@ type CreateContainerOptions struct { // that the container will be connected to eventually, and not at creation time. NetworkAliases []string + // CreationNetworks lists networks (by runtime name) to attach at creation time, together with + // per-network aliases. Used by runtimes that can only attach networks at creation time (see + // NetworksAttachedAtCreation). When non-empty, it takes precedence over Network/NetworkAliases. + CreationNetworks []CreationNetwork + // Healthcheck configuration for the container // This is currently only used for testing purposes Healthcheck ContainerHealthcheck @@ -310,6 +315,15 @@ type CreateContainerOptions struct { apiv1.ContainerSpec } +// CreationNetwork describes a network (by runtime name) and aliases to attach to a container at creation time. +type CreationNetwork struct { + // Runtime name of the network to attach. + Name string + + // Network aliases for the container on this network. + Aliases []string +} + type ContainerHealthcheck struct { // The command to run for the health check Command []string @@ -444,6 +458,12 @@ type CreateFilesOptions struct { type CreateFiles interface { // Create files/folders in the container based on the provided structure CreateFiles(ctx context.Context, options CreateFilesOptions) error + + // CreateFilesRequiresRunningContainer reports whether CreateFiles can only write into a running + // container. Runtimes that inject files by executing a command inside the container (e.g. wslc via + // `exec ... tar`) return true, so the controller defers the copy until after the container has + // started. Runtimes with a copy-into-created-container primitive (Docker/Podman `cp`) return false. + CreateFilesRequiresRunningContainer() bool } // ApplyImageLayers command types diff --git a/internal/containers/containers_common.go b/internal/containers/containers_common.go index 5d8f6f45..cc1df668 100644 --- a/internal/containers/containers_common.go +++ b/internal/containers/containers_common.go @@ -52,6 +52,64 @@ func (o StreamContainerLogsOptions) Apply(args []string) []string { return args } +// BuildCreateFilesTar builds an in-memory tar archive (rooted at "/") containing the entries described +// by options. It returns nil (with no error) when no content was produced, which can happen if every +// entry is marked ContinueOnError and all of them fail. The archive can be extracted into a container +// filesystem or used as an image layer. +func BuildCreateFilesTar(options CreateFilesOptions, log logr.Logger) ([]byte, error) { + tarWriter := usvc_io.NewTarWriter() + + certificateHashes := []string{} + for _, item := range options.Entries { + switch item.Type { + case apiv1.FileSystemEntryTypeDir: + if addDirectoryErr := AddDirectoryToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, log); addDirectoryErr != nil { + return nil, addDirectoryErr + } + case apiv1.FileSystemEntryTypeSymlink: + if addSymlinkErr := AddSymlinkToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, log); addSymlinkErr != nil { + if item.ContinueOnError { + log.Error(addSymlinkErr, "Failed to add symlink to tar archive, continuing", "SymLink", item) + } else { + return nil, addSymlinkErr + } + } + case apiv1.FileSystemEntryTypeOpenSSL: + hash, addCertErr := AddCertificateToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, certificateHashes, log) + if addCertErr != nil { + if item.ContinueOnError { + log.Error(addCertErr, "Failed to add a certificate to the tar file, but continueOnError is set", "Certificate", item) + } else { + return nil, addCertErr + } + } + + // Keep track of the certificate hashes we've added to this directory so that we can deal with the possibility of collisions + certificateHashes = append(certificateHashes, hash) + default: + if addFileErr := AddFileToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, log); addFileErr != nil { + if item.ContinueOnError { + log.Error(addFileErr, "Failed to add a file to the tar file, but continueOnError is set", "File", item) + } else { + return nil, addFileErr + } + } + } + } + + if tarWriter.Empty() { + // Can happen if all ContinueOnError items fail + return nil, nil + } + + buffer, bufferErr := tarWriter.Buffer() + if bufferErr != nil { + return nil, bufferErr + } + + return buffer.Bytes(), nil +} + func AddCertificateToTar(tarWriter *usvc_io.TarWriter, basePath string, owner int32, group int32, umask fs.FileMode, certificate apiv1.FileSystemEntry, modTime time.Time, hashes []string, log logr.Logger) (string, error) { if certificate.Type != apiv1.FileSystemEntryTypeOpenSSL { return "", fmt.Errorf("item is not a certificate") diff --git a/internal/containers/containers_common_test.go b/internal/containers/containers_common_test.go new file mode 100644 index 00000000..698bed55 --- /dev/null +++ b/internal/containers/containers_common_test.go @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package containers + +import ( + "archive/tar" + "bytes" + "io" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apiv1 "github.com/microsoft/dcp/api/v1" + "github.com/microsoft/dcp/pkg/osutil" +) + +// readTarEntries reads a tar archive and returns a map of entry name to its string contents. +func readTarEntries(t *testing.T, data []byte) map[string]string { + t.Helper() + entries := map[string]string{} + reader := tar.NewReader(bytes.NewReader(data)) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + + var contents bytes.Buffer + if header.Typeflag == tar.TypeReg { + _, copyErr := io.Copy(&contents, reader) + require.NoError(t, copyErr) + } else if header.Typeflag == tar.TypeSymlink { + contents.WriteString(header.Linkname) + } + entries[header.Name] = contents.String() + } + return entries +} + +func TestBuildCreateFilesTar_FilesAndSymlink(t *testing.T) { + options := CreateFilesOptions{ + Container: "ignored", + Destination: "/etc/app", + Umask: osutil.DefaultUmaskBitmask, + Entries: []apiv1.FileSystemEntry{ + { + Name: "config.txt", + Contents: "hello", + }, + { + Name: "link", + Type: apiv1.FileSystemEntryTypeSymlink, + Target: "./config.txt", + }, + }, + } + + tarBytes, err := BuildCreateFilesTar(options, logr.Discard()) + require.NoError(t, err) + require.NotNil(t, tarBytes) + + entries := readTarEntries(t, tarBytes) + assert.Equal(t, "hello", entries["/etc/app/config.txt"]) + assert.Equal(t, "./config.txt", entries["/etc/app/link"]) +} + +func TestBuildCreateFilesTar_EmptyReturnsNil(t *testing.T) { + options := CreateFilesOptions{ + Container: "ignored", + Destination: "/etc/app", + Umask: osutil.DefaultUmaskBitmask, + Entries: []apiv1.FileSystemEntry{}, + } + + tarBytes, err := BuildCreateFilesTar(options, logr.Discard()) + require.NoError(t, err) + assert.Nil(t, tarBytes) +} + +func TestBuildCreateFilesTar_Directory(t *testing.T) { + options := CreateFilesOptions{ + Container: "ignored", + Destination: "/etc/app", + Umask: osutil.DefaultUmaskBitmask, + Entries: []apiv1.FileSystemEntry{ + { + Name: "certs", + Type: apiv1.FileSystemEntryTypeDir, + Entries: []apiv1.FileSystemEntry{ + { + Name: "bundle.pem", + Contents: "cert-bytes", + }, + }, + }, + }, + } + + tarBytes, err := BuildCreateFilesTar(options, logr.Discard()) + require.NoError(t, err) + require.NotNil(t, tarBytes) + + entries := readTarEntries(t, tarBytes) + assert.Equal(t, "cert-bytes", entries["/etc/app/certs/bundle.pem"]) +} diff --git a/internal/containers/flags/container_runtime.go b/internal/containers/flags/container_runtime.go index 13a5d5e7..80c30655 100644 --- a/internal/containers/flags/container_runtime.go +++ b/internal/containers/flags/container_runtime.go @@ -21,10 +21,11 @@ const ( UnknownRuntime RuntimeFlagValue = "" DockerRuntime RuntimeFlagValue = "docker" PodmanRuntime RuntimeFlagValue = "podman" + WslcRuntime RuntimeFlagValue = "wslc" ) var ( - supportedRuntimeNames = []string{"docker", "podman"} + supportedRuntimeNames = []string{"docker", "podman", "wslc"} runtime = UnknownRuntime ) diff --git a/internal/containers/network_orchestrator.go b/internal/containers/network_orchestrator.go index aa4ddeda..9b6c704a 100644 --- a/internal/containers/network_orchestrator.go +++ b/internal/containers/network_orchestrator.go @@ -185,5 +185,11 @@ type NetworkOrchestrator interface { // Get default (bridge-type) network name DefaultNetworkName() string + // NetworksAttachedAtCreation reports whether the runtime can attach a container to networks only + // at creation time (true), versus being able to connect/disconnect a running container (false). + // Runtimes that return true (e.g. wslc) must be created already attached to their target networks + // and aliases; the controller skips the default-network create-then-detach flow for them. + NetworksAttachedAtCreation() bool + RuntimeStatusChecker } diff --git a/internal/containers/runtimes/runtime.go b/internal/containers/runtimes/runtime.go index 23bdc26c..fc6133c8 100644 --- a/internal/containers/runtimes/runtime.go +++ b/internal/containers/runtimes/runtime.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/dcp/internal/containers/flags" "github.com/microsoft/dcp/internal/docker" "github.com/microsoft/dcp/internal/podman" + "github.com/microsoft/dcp/internal/wslc" "github.com/microsoft/dcp/pkg/process" ) @@ -24,6 +25,7 @@ var ( supportedRuntimes = map[flags.RuntimeFlagValue]ContainerOrchestratorFactory{ flags.DockerRuntime: docker.NewDockerCliOrchestrator, flags.PodmanRuntime: podman.NewPodmanCliOrchestrator, + flags.WslcRuntime: wslc.NewWslcCliOrchestrator, } ) diff --git a/internal/docker/cli_orchestrator.go b/internal/docker/cli_orchestrator.go index 450e2f85..2dd5351a 100644 --- a/internal/docker/cli_orchestrator.go +++ b/internal/docker/cli_orchestrator.go @@ -842,6 +842,10 @@ func (dco *DockerCliOrchestrator) RemoveContainers(ctx context.Context, options return removed, err } +func (dco *DockerCliOrchestrator) CreateFilesRequiresRunningContainer() bool { + return false +} + func (dco *DockerCliOrchestrator) CreateFiles(ctx context.Context, options containers.CreateFilesOptions) error { args := []string{"container", "cp"} @@ -1091,6 +1095,10 @@ func (dco *DockerCliOrchestrator) DefaultNetworkName() string { return "bridge" } +func (dco *DockerCliOrchestrator) NetworksAttachedAtCreation() bool { + return false +} + func (dco *DockerCliOrchestrator) doWatchContainers(watcherCtx context.Context, ss *pubsub.SubscriptionSet[containers.EventMessage]) { args := []string{"events", "--filter", "type=container", "--format", "{{json .}}"} cmd := makeDockerCommand(args...) diff --git a/internal/podman/cli_orchestrator.go b/internal/podman/cli_orchestrator.go index 111c5d79..dfefe75a 100644 --- a/internal/podman/cli_orchestrator.go +++ b/internal/podman/cli_orchestrator.go @@ -824,6 +824,10 @@ func (pco *PodmanCliOrchestrator) RemoveContainers(ctx context.Context, options return removed, err } +func (pco *PodmanCliOrchestrator) CreateFilesRequiresRunningContainer() bool { + return false +} + func (pco *PodmanCliOrchestrator) CreateFiles(ctx context.Context, options containers.CreateFilesOptions) error { args := []string{"container", "cp"} @@ -1083,6 +1087,10 @@ func (pco *PodmanCliOrchestrator) DefaultNetworkName() string { return "podman" } +func (pco *PodmanCliOrchestrator) NetworksAttachedAtCreation() bool { + return false +} + func (pco *PodmanCliOrchestrator) doWatchContainers(watcherCtx context.Context, ss *pubsub.SubscriptionSet[containers.EventMessage]) { args := []string{"events", "--filter", "type=container", "--format", "json"} cmd := makePodmanCommand(args...) diff --git a/internal/testutil/ctrlutil/test_container_orchestrator.go b/internal/testutil/ctrlutil/test_container_orchestrator.go index 4f209a0e..b786503a 100644 --- a/internal/testutil/ctrlutil/test_container_orchestrator.go +++ b/internal/testutil/ctrlutil/test_container_orchestrator.go @@ -1136,6 +1136,10 @@ func (to *TestContainerOrchestrator) DefaultNetworkName() string { return "bridge" } +func (to *TestContainerOrchestrator) NetworksAttachedAtCreation() bool { + return false +} + func (to *TestContainerOrchestrator) BuildImage(ctx context.Context, options containers.BuildImageOptions) error { to.mutex.Lock() defer to.mutex.Unlock() @@ -2079,6 +2083,10 @@ func (to *TestContainerOrchestrator) InspectContainers(ctx context.Context, opti return result, err } +func (to *TestContainerOrchestrator) CreateFilesRequiresRunningContainer() bool { + return false +} + func (to *TestContainerOrchestrator) CreateFiles(ctx context.Context, options containers.CreateFilesOptions) error { to.mutex.Lock() defer to.mutex.Unlock() diff --git a/internal/wslc/cli_orchestrator.go b/internal/wslc/cli_orchestrator.go new file mode 100644 index 00000000..68aa8c0b --- /dev/null +++ b/internal/wslc/cli_orchestrator.go @@ -0,0 +1,1612 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/go-logr/logr" + + apiv1 "github.com/microsoft/dcp/api/v1" + "github.com/microsoft/dcp/pkg/concurrency" + usvc_io "github.com/microsoft/dcp/pkg/io" + "github.com/microsoft/dcp/pkg/osutil" + "github.com/microsoft/dcp/pkg/process" + "github.com/microsoft/dcp/pkg/randdata" + "github.com/microsoft/dcp/pkg/slices" + + "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/internal/dcpproc" + "github.com/microsoft/dcp/internal/networking" + "github.com/microsoft/dcp/internal/pubsub" + "github.com/microsoft/dcp/internal/termpty" +) + +var ( + // wslc reports "not found" errors on standard error with these shapes (exit code 1): + // container: Container '' not found. + // network: Network not found: '' + // volume: Volume not found: '' + containerNotFoundRegEx = regexp.MustCompile(`(?i)container '.*' not found`) + networkNotFoundRegEx = regexp.MustCompile(`(?i)network not found`) + volumeNotFoundRegEx = regexp.MustCompile(`(?i)volume not found`) + // wslc reports a missing image as "Image '' not found." on inspect, and as + // "Error code: WSLC_E_IMAGE_NOT_FOUND" (or "pull access denied ...") on pull. + imageNotFoundRegEx = regexp.MustCompile(`(?i)(image '.*' not found|image not found|image_not_found|no such image)`) + // Creating an object that already exists reports a Windows error code (e.g. ERROR_ALREADY_EXISTS) + // or the underlying "... already exists" message. + alreadyExistsRegEx = regexp.MustCompile(`(?i)error_already_exists|already exists`) + // TODO: refine once the exact message emitted when the wslc service is unavailable is confirmed. + runtimeNotHealthyRegEx = regexp.MustCompile(`(?i)(wsl.*is not running|could not connect|service is not running|the wsl service)`) + + // Extracts the version number from the single-line `wslc version` output, e.g. "wslc 2.9.3.0". + versionRegEx = regexp.MustCompile(`(?i)wslc\s+([0-9][0-9.]*)`) + // Matches the exec failure emitted when the container image lacks a `tar` binary, used to + // produce a clearer diagnostic for in-container file injection (wslc has no `container cp`). + tarMissingRegEx = regexp.MustCompile(`(?i)(tar)(:|.{0,20})(not found|no such file|executable file not found|command not found)`) + + newContainerNotFoundErrorMatch = containers.NewCliErrorMatch(containerNotFoundRegEx, errors.Join(containers.ErrNotFound, fmt.Errorf("container not found"))) + newVolumeNotFoundErrorMatch = containers.NewCliErrorMatch(volumeNotFoundRegEx, errors.Join(containers.ErrNotFound, fmt.Errorf("volume not found"))) + newNetworkNotFoundErrorMatch = containers.NewCliErrorMatch(networkNotFoundRegEx, errors.Join(containers.ErrNotFound, fmt.Errorf("network not found"))) + imageNotFoundErrorMatch = containers.NewCliErrorMatch(imageNotFoundRegEx, errors.Join(containers.ErrNotFound, fmt.Errorf("image not found"))) + newNetworkAlreadyExistsErrorMatch = containers.NewCliErrorMatch(alreadyExistsRegEx, errors.Join(containers.ErrAlreadyExists, fmt.Errorf("network already exists"))) + newVolumeAlreadyExistsErrorMatch = containers.NewCliErrorMatch(alreadyExistsRegEx, errors.Join(containers.ErrAlreadyExists, fmt.Errorf("volume already exists"))) + newWslcNotRunningErrorMatch = containers.NewCliErrorMatch(runtimeNotHealthyRegEx, errors.Join(containers.ErrRuntimeNotHealthy, fmt.Errorf("wslc runtime is not healthy"))) + + // We expect almost all wslc CLI invocations to finish within this time. + ordinaryWslcCommandTimeout = 30 * time.Second + + // We allow up to a minute for diagnostic commands to finish as we'd rather wait a bit longer than miss information. + diagnosticWslcCommandTimeout = 1 * time.Minute + + defaultBuildImageTimeout = 10 * time.Minute + defaultPullImageTimeout = 10 * time.Minute + defaultCreateContainerTimeout = 10 * time.Minute + defaultRunContainerTimeout = 10 * time.Minute + + // Cache and synchronization control for checking runtime cachedStatus + cachedStatus *containers.ContainerRuntimeStatus + // Ensure that only one goroutine is checking the status at a time + checkStatusLock = concurrency.NewContextAwareLock() + // Mutex to control read/write access to the cached status + updateStatus = &sync.RWMutex{} + backgroundStatusUpdates atomic.Int32 +) + +type WslcCliOrchestrator struct { + log logr.Logger + + // Process executor for running wslc commands + executor process.Executor + + // Event watcher for container events + containerEvtWatcher *pubsub.SubscriptionSet[containers.EventMessage] + + // Event watcher for network events + networkEvtWatcher *pubsub.SubscriptionSet[containers.EventMessage] +} + +func NewWslcCliOrchestrator(log logr.Logger, executor process.Executor) containers.ContainerOrchestrator { + wco := &WslcCliOrchestrator{ + log: log, + executor: executor, + } + + wco.containerEvtWatcher = pubsub.NewSubscriptionSet(wco.doWatchContainers, context.Background()) + wco.networkEvtWatcher = pubsub.NewSubscriptionSet(wco.doWatchNetworks, context.Background()) + + return wco +} + +func (*WslcCliOrchestrator) IsDefault() bool { + return false +} + +func (*WslcCliOrchestrator) Name() string { + return "wslc" +} + +func (*WslcCliOrchestrator) ContainerHost() string { + // wslc does not resolve host.containers.internal; the Docker-style alias is available instead. + return "host.docker.internal" +} + +func (wco *WslcCliOrchestrator) CheckStatus(ctx context.Context, cacheUsage containers.CachedRuntimeStatusUsage) containers.ContainerRuntimeStatus { + // A cached status is already available, return it + updateStatus.RLock() + if cachedStatus != nil && cacheUsage == containers.CachedRuntimeStatusAllowed { + defer updateStatus.RUnlock() + return *cachedStatus + } + updateStatus.RUnlock() + + if cacheUsage == containers.CachedRuntimeStatusAllowed { + // For cached results, only one goroutine should be checking the status at a time + if syncErr := checkStatusLock.Lock(ctx); syncErr != nil { + // Timed out, assume wslc is not responsive and unavailable + return containers.ContainerRuntimeStatus{ + Installed: false, + Running: false, + Error: "Timed out while checking wslc status; wslc CLI is not responsive.", + } + } + + defer checkStatusLock.Unlock() + } + + updateStatus.RLock() + // Check again if the status is available in the cache + if cachedStatus != nil && cacheUsage == containers.CachedRuntimeStatusAllowed { + defer updateStatus.RUnlock() + return *cachedStatus + } + updateStatus.RUnlock() + + newStatus := wco.getStatus(ctx) + updateStatus.Lock() + // Update the cached status + cachedStatus = &newStatus + updateStatus.Unlock() + + return newStatus +} + +// Check the status of the wslc runtime in the background until the context is canceled. +func (wco *WslcCliOrchestrator) EnsureBackgroundStatusUpdates(ctx context.Context) { + if !backgroundStatusUpdates.CompareAndSwap(0, 1) { + return + } + + go func() { + timer := time.NewTimer(0) + timer.Stop() + for { + // Only one goroutine should be checking the status at a time + if checkStatusLock.TryLock() { + newStatus := wco.getStatus(ctx) + + updateStatus.Lock() + // Update the cached status + cachedStatus = &newStatus + updateStatus.Unlock() + } + + // Wait for 5 seconds before checking again + timer.Reset(5 * time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + continue + } + } + }() +} + +func (wco *WslcCliOrchestrator) getStatus(ctx context.Context) containers.ContainerRuntimeStatus { + cmd := makeWslcCommand("container", "list", "--last", "1", "--quiet") + _, stdErr, err := wco.runBufferedWslcCommand(ctx, "Info", cmd, nil, nil, ordinaryWslcCommandTimeout) + + if errors.Is(err, exec.ErrNotFound) { + // Try to get the inner error if this is an exec.ErrNotFound error + if unwrapErr := errors.Unwrap(err); errors.Is(unwrapErr, exec.ErrNotFound) { + err = unwrapErr + } + + // Couldn't find the wslc CLI, so it's not installed + return containers.ContainerRuntimeStatus{ + Installed: false, + Running: false, + Error: err.Error(), + } + } else if err != nil { + var stdErrString string + + // Prefer returning any stderr from the runtime command, but if that is empty, use the error message from the error object. + // The goal is to make it easy for users to diagnose underlying container runtime issues based on the error message. + if stdErr != nil { + stdErrString = strings.TrimSpace(stdErr.String()) + } + + if stdErrString == "" { + stdErrString = err.Error() + } + + // Error response from the wslc command, assume runtime isn't available + return containers.ContainerRuntimeStatus{ + Installed: true, + Running: false, + Error: stdErrString, + } + } + + // Info command returned successfully, assume runtime is ready + return containers.ContainerRuntimeStatus{ + Installed: true, + Running: true, + } +} + +func (wco *WslcCliOrchestrator) GetDiagnostics(ctx context.Context) (containers.ContainerDiagnostics, error) { + // wslc reports its version as a single plain-text line (e.g. "wslc 2.9.3.0") and does not + // support a --format option, so the output is parsed with a regular expression. + cmd := makeWslcCommand("version") + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "Version", cmd, nil, nil, diagnosticWslcCommandTimeout) + if err != nil { + return containers.ContainerDiagnostics{}, errors.Join(err, normalizeCliErrors(errBuf)) + } + + return parseDiagnostics(outBuf.Bytes()) +} + +func (wco *WslcCliOrchestrator) CreateVolume(ctx context.Context, options containers.CreateVolumeOptions) error { + cmd := makeWslcCommand("volume", "create", options.Name) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "CreateVolume", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + return errors.Join(err, normalizeCliErrors(errBuf, newVolumeAlreadyExistsErrorMatch.MaxObjects(1))) + } + + return containers.ExpectCliStrings(outBuf, []string{options.Name}) +} + +func (wco *WslcCliOrchestrator) InspectVolumes(ctx context.Context, options containers.InspectVolumesOptions) ([]containers.InspectedVolume, error) { + if len(options.Volumes) == 0 { + return nil, fmt.Errorf("must specify at least one volume") + } + + // wslc inspect commands do not support a --format option and return JSON by default. + cmd := makeWslcCommand(append([]string{"volume", "inspect"}, options.Volumes...)...) + + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "InspectVolumes", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + err = errors.Join(err, normalizeCliErrors(errBuf, newVolumeNotFoundErrorMatch.MaxObjects(len(options.Volumes)))) + } + + inspectedVolumes, unmarshalErr := asObjects(outBuf, unmarshalVolume) + err = errors.Join(err, unmarshalErr) + + if len(inspectedVolumes) < len(options.Volumes) { + err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all volumes were inspected, expected %d but got %d", len(options.Volumes), len(inspectedVolumes)))) + } + + return inspectedVolumes, err +} + +func (wco *WslcCliOrchestrator) RemoveVolumes(ctx context.Context, options containers.RemoveVolumesOptions) ([]string, error) { + if len(options.Volumes) == 0 { + return nil, fmt.Errorf("must specify at least one volume") + } + + args := []string{"volume", "remove"} + if options.Force { + args = append(args, "--force") + } + + args = append(args, options.Volumes...) + + cmd := makeWslcCommand(args...) + + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "RemoveVolumes", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + err = errors.Join(err, normalizeCliErrors(errBuf, newVolumeNotFoundErrorMatch.MaxObjects(len(options.Volumes)))) + } + + nonEmpty := slices.NonEmpty[byte](bytes.Split(outBuf.Bytes(), osutil.LF())) + removed := slices.Map[string](nonEmpty, func(bs []byte) string { return string(bs) }) + + if len(removed) < len(options.Volumes) { + err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all volumes were removed, expected %d but got %d", len(options.Volumes), len(removed)))) + } + + return removed, err +} + +func (wco *WslcCliOrchestrator) BuildImage(ctx context.Context, options containers.BuildImageOptions) error { + args := []string{"image", "build"} + + if options.Dockerfile != "" { + args = append(args, "-f", options.Dockerfile) + } + + // Should base images be updated even if they are already present locally? + if options.Pull { + args = append(args, "--pull") + } + + // Apply all tags specified in the build context to the image + for _, tag := range options.Tags { + args = append(args, "-t", tag) + } + + // Apply all specified build arguments + for _, buildArg := range options.Args { + if buildArg.Value != "" { + args = append(args, "--build-arg", fmt.Sprintf("%s=%s", buildArg.Name, buildArg.Value)) + } else { + args = append(args, "--build-arg", buildArg.Name) + } + } + + // If a build stage is given, use it + if options.Stage != "" { + args = append(args, "--target", options.Stage) + } + + // Apply any specified labels + for _, label := range options.Labels { + args = append(args, "--label", fmt.Sprintf("%s=%s", label.Key, label.Value)) + } + + // TODO: wslc build does not support build secrets, --iidfile, or --platform. Secrets and + // platform selection are silently ignored; warn so the limitation is visible in logs. + if len(options.Secrets) > 0 { + wco.log.Info("wslc does not support build secrets; the secrets will not be available to the build") + } + if options.Platform != "" { + wco.log.Info("wslc does not support selecting a build platform; building for the host platform", "Platform", options.Platform) + } + + // Append the build context argument + args = append(args, options.Context) + + cmd := makeWslcCommand(args...) + + // Building an image can take a long time to finish, particularly if any base images are not available locally. + // Use a much longer timeout than for other commands. + if options.Timeout == 0 { + options.Timeout = defaultBuildImageTimeout + } + _, errBuf, err := wco.runBufferedWslcCommand(ctx, "BuildImage", cmd, options.StdOutStream, options.StdErrStream, options.Timeout) + if err != nil { + return errors.Join(err, normalizeCliErrors(errBuf)) + } + + // wslc has no --iidfile flag, so when the caller expects the built image ID to be written + // to a file, resolve it from the first tag and write it ourselves. + if options.IidFile != "" && len(options.Tags) > 0 { + inspected, inspectErr := wco.InspectImages(ctx, containers.InspectImagesOptions{Images: []string{options.Tags[0]}}) + if inspectErr != nil || len(inspected) == 0 { + wco.log.Error(inspectErr, "Could not resolve built image ID for the image ID file", "Tag", options.Tags[0]) + } else if writeErr := usvc_io.WriteFile(options.IidFile, []byte(inspected[0].Id), osutil.PermissionOwnerReadWriteOthersRead); writeErr != nil { + return errors.Join(writeErr, fmt.Errorf("could not write image ID file '%s'", options.IidFile)) + } + } + + return nil +} + +func (wco *WslcCliOrchestrator) InspectImages(ctx context.Context, options containers.InspectImagesOptions) ([]containers.InspectedImage, error) { + if len(options.Images) == 0 { + return nil, fmt.Errorf("must specify at least one image") + } + + var inspectedImages []containers.InspectedImage + + cmd := makeWslcCommand(append([]string{"image", "inspect"}, options.Images...)...) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "InspectImages", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + err = errors.Join(err, normalizeCliErrors(errBuf, imageNotFoundErrorMatch.MaxObjects(len(options.Images)))) + } else { + inspectedImages, err = asObjects(outBuf, unmarshalImage) + + if len(inspectedImages) < len(options.Images) { + err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all images were inspected, expected %d but got %d", len(options.Images), len(inspectedImages)))) + } + } + + return inspectedImages, err +} + +func (wco *WslcCliOrchestrator) PullImage(ctx context.Context, options containers.PullImageOptions) (string, error) { + if options.Image == "" { + return "", fmt.Errorf("must specify an image to pull") + } + + // wslc image pull has no flags; a specific digest is selected via the image reference. + ref := options.Image + if options.Digest != "" { + ref = options.Image + "@" + options.Digest + } + + cmd := makeWslcCommand("image", "pull", ref) + // Pulling large images can take a long time, especially if the image is not available locally and the network is slow. + if options.Timeout == 0 { + options.Timeout = defaultPullImageTimeout + } + _, errBuf, err := wco.runBufferedWslcCommand(ctx, "PullImage", cmd, nil, nil, options.Timeout) + if err != nil { + return "", errors.Join(err, normalizeCliErrors(errBuf, imageNotFoundErrorMatch)) + } + + // wslc pull does not emit the image ID, so resolve it by inspecting the pulled reference. + inspected, inspectErr := wco.InspectImages(ctx, containers.InspectImagesOptions{Images: []string{ref}}) + if inspectErr != nil || len(inspected) == 0 { + // Best effort: return the reference if the image ID cannot be resolved. + return ref, nil + } + + return inspected[0].Id, nil +} + +func applyCreateContainerOptions(args []string, options containers.CreateContainerOptions) []string { + if options.Name != "" { + args = append(args, "--name", options.Name) + } + + if len(options.CreationNetworks) > 0 { + // wslc attaches networks only at creation time and supports repeating --network for multiple + // networks, each followed by its own --network-alias flags. + for _, network := range options.CreationNetworks { + args = append(args, "--network", network.Name) + for _, alias := range network.Aliases { + args = append(args, "--network-alias", alias) + } + } + } else if options.Network != "" { + args = append(args, "--network", options.Network) + + if len(options.NetworkAliases) > 0 { + for _, alias := range options.NetworkAliases { + args = append(args, "--network-alias", alias) + } + } + } + + // wslc uses Docker-style bind syntax for mounts; the --mount option is not supported. + for _, mount := range options.VolumeMounts { + mountVal := mount.Target + if mount.Source != "" { + mountVal = fmt.Sprintf("%s:%s", mount.Source, mount.Target) + } + if mount.ReadOnly { + mountVal += ":ro" + } + args = append(args, "-v", mountVal) + } + + for _, port := range options.Ports { + portVal := fmt.Sprintf("%d", port.ContainerPort) + + if port.HostPort != 0 { + portVal = fmt.Sprintf("%d:%s", port.HostPort, portVal) + } else { + portVal = fmt.Sprintf(":%s", portVal) + } + + if port.HostIP != "" { + portVal = fmt.Sprintf("%s:%s", port.HostIP, portVal) + } else { + // Bind to 127.0.0.1 for extra security, not to 0.0.0.0 (all interfaces, making it accessible from the outside) + // IPv6 is not well supported with container networking, so we assume IPv4. We'll need to revisit this logic + // if we start getting requests to support IPv6 container networking. + portVal = fmt.Sprintf("%s:%s", networking.IPv4LocalhostDefaultAddress, portVal) + } + + if port.Protocol != "" { + // wslc only accepts lowercase protocol names (tcp/udp); the API uses uppercase (TCP/UDP). + portVal = fmt.Sprintf("%s/%s", portVal, strings.ToLower(string(port.Protocol))) + } + + args = append(args, "-p", portVal) + } + + for _, envVar := range options.Env { + eVal := fmt.Sprintf("%s=%s", envVar.Name, envVar.Value) + args = append(args, "-e", eVal) + } + + for _, envFile := range options.EnvFiles { + args = append(args, "--env-file", envFile) + } + + for _, label := range options.Labels { + args = append(args, "--label", fmt.Sprintf("%s=%s", label.Key, label.Value)) + } + + // TODO: wslc create does not support restart policies, pull policies, or healthcheck + // configuration, so those options are not applied here. + + if options.Command != "" { + args = append(args, "--entrypoint", options.Command) + } + + if options.Terminal != nil { + // Attach a TTY (-t) and keep STDIN open (-i) if a terminal is requested. + // wslc does not support bundled short flags, so they are passed separately. + args = append(args, "-i", "-t") + } + + args = append(args, options.RunArgs...) + + return args +} + +func (wco *WslcCliOrchestrator) CreateContainer(ctx context.Context, options containers.CreateContainerOptions) (string, error) { + args := []string{"container", "create"} + + args = applyCreateContainerOptions(args, options) + + args = append(args, options.Image) + + if len(options.Args) > 0 { + args = append(args, options.Args...) + } + + cmd := makeWslcCommand(args...) + + // Create container can take a long time to finish if the image is not available locally. + // Use a much longer timeout than for other commands. + if options.Timeout == 0 { + options.Timeout = defaultCreateContainerTimeout + } + + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "CreateContainer", cmd, options.StdOutStream, options.StdErrStream, options.Timeout) + if err != nil { + err = errors.Join(err, normalizeCliErrors(errBuf)) + if id, err2 := asId(outBuf); err2 == nil { + // We got an ID, so the container was created, but the command failed. + return id, err + } + + return "", err + } + + return asId(outBuf) +} + +func (wco *WslcCliOrchestrator) RunContainer(ctx context.Context, options containers.RunContainerOptions) (string, error) { + args := []string{"container", "run"} + + args = applyCreateContainerOptions(args, options.CreateContainerOptions) + + args = append(args, "--detach") + args = append(args, options.Image) + + if len(options.Args) > 0 { + args = append(args, options.Args...) + } + + cmd := makeWslcCommand(args...) + + // The run container command can take a long time to finish if the image is not available locally. + // So we use much longer timeout than for other commands. + if options.Timeout == 0 { + options.Timeout = defaultRunContainerTimeout + } + + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "RunContainer", cmd, options.StdOutStream, options.StdErrStream, options.Timeout) + if err != nil { + return "", errors.Join(err, normalizeCliErrors(errBuf)) + } + + return asId(outBuf) +} + +func (wco *WslcCliOrchestrator) ExecContainer(ctx context.Context, options containers.ExecContainerOptions) (<-chan int32, error) { + args := []string{"container", "exec"} + + if options.WorkingDirectory != "" { + args = append(args, "--workdir", options.WorkingDirectory) + } + + for _, envVar := range options.Env { + eVal := fmt.Sprintf("%s=%s", envVar.Name, envVar.Value) + args = append(args, "-e", eVal) + } + + for _, envFile := range options.EnvFiles { + args = append(args, "--env-file", envFile) + } + + args = append(args, options.Container) + args = append(args, options.Command) + if len(options.Args) > 0 { + args = append(args, options.Args...) + } + + cmd := makeWslcCommand(args...) + + cmd.Stdout = options.StdOutStream + cmd.Stderr = options.StdErrStream + + exitCh := make(chan int32) + exitHandler := func(_ process.Pid_t, exitCode int32, err error) { + // We only care about the exit code, not the error. The only scenario where we should get an error + // is if the context for an exec command is canceled during DCP shutdown, in which case that's expected. + if err != nil && !errors.Is(err, context.Canceled) { + wco.log.Error(err, "Unexpected error during container exec command", "Command", cmd.String()) + } + exitCh <- exitCode + close(exitCh) + } + + wco.log.V(1).Info("Running wslc command", "Command", cmd.String()) + _, _, startWaitForProcessExit, err := wco.executor.StartProcess(ctx, cmd, process.ProcessExitHandlerFunc(exitHandler), process.CreationFlagsNone, nil) + if err != nil { + close(exitCh) + return nil, errors.Join(err, fmt.Errorf("failed to start wslc command '%s'", "ExecContainer")) + } + startWaitForProcessExit() + + return exitCh, nil +} + +// Run `wslc container attach ` on a freshly allocated pseudo-terminal. +func (wco *WslcCliOrchestrator) AttachContainer(ctx context.Context, options containers.AttachContainerOptions) (*termpty.PseudoTerminalProcess, error) { + cmd := makeWslcCommand("container", "attach", options.Container) + return termpty.StartProcessWithTerminal(ctx, wco.executor, &termpty.CommandSpec{ + Cmd: cmd, + CreationFlags: process.CreationFlagEnsureKillOnDispose, + Cols: options.Cols, + Rows: options.Rows, + }) +} + +func (wco *WslcCliOrchestrator) ListContainers(ctx context.Context, options containers.ListContainersOptions) ([]containers.ListedContainer, error) { + args := []string{"container", "list", "--no-trunc"} + + for _, label := range options.Filters.LabelFilters { + filter := fmt.Sprintf("label=%s", label.Key) + + if label.Value != "" { + filter += "=" + label.Value + } + + args = append(args, "--filter", filter) + } + + args = append(args, "--format", "json") + + cmd := makeWslcCommand(args...) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "ListContainers", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + return nil, errors.Join(err, normalizeCliErrors(errBuf)) + } + + return asObjects(outBuf, unmarshalListedContainer) +} + +func (wco *WslcCliOrchestrator) InspectContainers(ctx context.Context, options containers.InspectContainersOptions) ([]containers.InspectedContainer, error) { + if len(options.Containers) == 0 { + return nil, fmt.Errorf("must specify at least one container") + } + + cmd := makeWslcCommand(append([]string{"container", "inspect"}, options.Containers...)...) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "InspectContainers", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + err = errors.Join(err, normalizeCliErrors(errBuf, newContainerNotFoundErrorMatch.MaxObjects(len(options.Containers)))) + } + + inspectedContainers, unmarshalErr := asObjects(outBuf, unmarshalContainer) + err = errors.Join(err, unmarshalErr) + + if len(inspectedContainers) < len(options.Containers) { + err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all containers were inspected, expected %d but got %d", len(options.Containers), len(inspectedContainers)))) + } + + return inspectedContainers, err +} + +func (wco *WslcCliOrchestrator) StartContainers(ctx context.Context, options containers.StartContainersOptions) ([]string, error) { + if len(options.Containers) == 0 { + return nil, fmt.Errorf("must specify at least one container") + } + + args := []string{"container", "start"} + args = append(args, options.Containers...) + + cmd := makeWslcCommand(args...) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "StartContainers", cmd, options.StdOutStream, options.StdErrStream, ordinaryWslcCommandTimeout) + if err != nil { + err = normalizeCliErrors(errBuf, newContainerNotFoundErrorMatch.MaxObjects(len(options.Containers))) + } + + nonEmpty := slices.NonEmpty[byte](bytes.Split(outBuf.Bytes(), osutil.LF())) + started := slices.Map[string](nonEmpty, func(bs []byte) string { return string(bs) }) + + if len(started) < len(options.Containers) { + err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all containers were started, expected %d but got %d", len(options.Containers), len(started)))) + } + + return started, err +} + +func (wco *WslcCliOrchestrator) StopContainers(ctx context.Context, options containers.StopContainersOptions) ([]string, error) { + if len(options.Containers) == 0 { + return nil, fmt.Errorf("must specify at least one container") + } + + args := []string{"container", "stop"} + var timeout time.Duration = ordinaryWslcCommandTimeout + if options.SecondsToKill > 0 { + args = append(args, "--time", fmt.Sprintf("%d", options.SecondsToKill)) + timeout = time.Duration(options.SecondsToKill)*time.Second + ordinaryWslcCommandTimeout + } + args = append(args, options.Containers...) + + cmd := makeWslcCommand(args...) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "StopContainers", cmd, nil, nil, timeout) + if err != nil { + err = errors.Join(err, normalizeCliErrors(errBuf, newContainerNotFoundErrorMatch.MaxObjects(len(options.Containers)))) + } + + nonEmpty := slices.NonEmpty[byte](bytes.Split(outBuf.Bytes(), osutil.LF())) + stopped := slices.Map[string](nonEmpty, func(bs []byte) string { return string(bs) }) + + if len(stopped) < len(options.Containers) { + err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all containers were stopped, expected %d but got %d", len(options.Containers), len(stopped)))) + } + + return stopped, err +} + +func (wco *WslcCliOrchestrator) RemoveContainers(ctx context.Context, options containers.RemoveContainersOptions) ([]string, error) { + if len(options.Containers) == 0 { + return nil, fmt.Errorf("must specify at least one container") + } + + // wslc container remove does not support removing associated anonymous volumes (-v). + args := []string{"container", "remove"} + if options.Force { + args = append(args, "--force") + } + args = append(args, options.Containers...) + + cmd := makeWslcCommand(args...) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "RemoveContainers", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + err = errors.Join(err, normalizeCliErrors(errBuf, newContainerNotFoundErrorMatch.MaxObjects(len(options.Containers)))) + } + + nonEmpty := slices.NonEmpty[byte](bytes.Split(outBuf.Bytes(), osutil.LF())) + removed := slices.Map[string](nonEmpty, func(bs []byte) string { return string(bs) }) + + if len(removed) < len(options.Containers) { + err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all containers were removed, expected %d but got %d", len(options.Containers), len(removed)))) + } + + return removed, err +} + +// CreateFilesRequiresRunningContainer reports that wslc can only inject files into a running container. +// The wslc CLI has no "copy into a created container" primitive; CreateFiles writes files by executing +// `tar` inside the container, which requires the container to be running. +func (wco *WslcCliOrchestrator) CreateFilesRequiresRunningContainer() bool { + return true +} + +func (wco *WslcCliOrchestrator) CreateFiles(ctx context.Context, options containers.CreateFilesOptions) error { + tarBytes, buildErr := containers.BuildCreateFilesTar(options, wco.log) + if buildErr != nil { + return buildErr + } + + if tarBytes == nil { + // Can happen if all ContinueOnError items fail + return nil + } + + // wslc has no `container cp` command, so the tar stream is extracted by running tar + // inside the container. This requires `tar` to be present in the container image. + cmd := makeWslcCommand("container", "exec", "-i", options.Container, "tar", "-xf", "-", "-C", "/") + cmd.Stdin = bytes.NewReader(tarBytes) + + _, errBuf, err := wco.runBufferedWslcCommand(ctx, "CopyFile", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + return buildCreateFilesError(options.Container, errBuf.String(), err) + } + + return nil +} + +// buildCreateFilesError composes the error returned when extracting files into a container fails. +// A missing tar binary surfaces as an exec failure from the container, not as a container-not-found +// error, so a hint is added pointing at the most likely cause (file injection needs `tar` on PATH). +func buildCreateFilesError(container, stderr string, err error) error { + normalizedErr := normalizeCliErrors(bytes.NewBufferString(stderr), newContainerNotFoundErrorMatch.MaxObjects(1)) + if !errors.Is(normalizedErr, containers.ErrNotFound) && tarMissingRegEx.MatchString(stderr) { + return errors.Join(fmt.Errorf("could not inject files into container %s: the container image must include a `tar` binary on PATH (wslc has no `container cp` command, so files are extracted via in-container tar): %w", container, err), normalizedErr) + } + return errors.Join(err, normalizedErr) +} + +// buildImageMutex serializes wslc image builds across the whole process. wslc (v2.9.3.0) mounts each +// build's context directory as a volume into the shared CLI session and caps mounts at 15 per session; +// concurrent builds also fail intermittently with a "Catastrophic failure"/E_UNEXPECTED error and leak +// their mounts, eventually exhausting the session. A successful build releases its mount, so serializing +// builds keeps mount usage bounded and avoids the concurrency failures. +var buildImageMutex sync.Mutex + +// ApplyImageLayers builds a derived image by applying additional tar layers on top of a base image. +// Unlike Docker/Podman, the wslc `build` command does not support reading a build context from stdin +// (its sole positional argument is an on-disk context directory), so the build context (a generated +// Dockerfile plus the layer tars) is materialized into a temporary directory and removed afterwards. +func (wco *WslcCliOrchestrator) ApplyImageLayers(ctx context.Context, options containers.ApplyImageLayersOptions) (string, error) { + if len(options.Layers) == 0 { + return "", fmt.Errorf("at least one image layer must be specified") + } + + // wslc build cannot read the build context from stdin, so a tag is required to identify the + // resulting image without parsing the verbose build output. + if options.Tag == "" { + return "", fmt.Errorf("wslc requires a tag to build a derived image with image layers") + } + + randomSuffix, randErr := randdata.MakeRandomString(8) + if randErr != nil { + return "", fmt.Errorf("generating temporary build context name: %w", randErr) + } + + contextDir, contextErr := usvc_io.CreateTempFolder(fmt.Sprintf("wslc-build-%s", string(randomSuffix)), 0700) + if contextErr != nil { + return "", fmt.Errorf("creating temporary build context directory: %w", contextErr) + } + defer func() { + if removeErr := os.RemoveAll(contextDir); removeErr != nil { + wco.log.Error(removeErr, "Could not remove temporary build context directory", "Directory", contextDir) + } + }() + + // Use a tag if available, otherwise fall back to the image ID for the FROM directive. + baseImage := options.BaseImage.Id + if len(options.BaseImage.Tags) > 0 { + baseImage = options.BaseImage.Tags[0] + } + + dockerfile := fmt.Sprintf("FROM %s\n", baseImage) + for i := range options.Layers { + layer := &options.Layers[i] + layerFileName := fmt.Sprintf("layer%d.tar", i) + + if writeErr := writeImageLayerFile(filepath.Join(contextDir, layerFileName), layer); writeErr != nil { + return "", fmt.Errorf("writing image layer %d to build context: %w", i, writeErr) + } + + dockerfile += fmt.Sprintf("ADD %s /\n", layerFileName) + } + + dockerfilePath := filepath.Join(contextDir, "Dockerfile") + dockerfileFile, dockerfileErr := usvc_io.OpenFile(dockerfilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if dockerfileErr != nil { + return "", fmt.Errorf("creating Dockerfile in build context: %w", dockerfileErr) + } + if _, writeErr := dockerfileFile.WriteString(dockerfile); writeErr != nil { + _ = dockerfileFile.Close() + return "", fmt.Errorf("writing Dockerfile in build context: %w", writeErr) + } + if closeErr := dockerfileFile.Close(); closeErr != nil { + return "", fmt.Errorf("closing Dockerfile in build context: %w", closeErr) + } + + timeout := options.Timeout + if timeout == 0 { + timeout = defaultBuildImageTimeout + } + + cmd := makeWslcCommand("build", "-t", options.Tag, contextDir) + // Serialize the build invocation; see buildImageMutex. + buildImageMutex.Lock() + _, errBuf, buildErr := wco.runBufferedWslcCommand(ctx, "ApplyImageLayers", cmd, nil, nil, timeout) + buildImageMutex.Unlock() + if buildErr != nil { + return "", errors.Join(fmt.Errorf("building derived image with image layers"), buildErr, normalizeCliErrors(errBuf)) + } + + wco.log.V(1).Info("Built derived image with image layers", "ImageRef", options.Tag, "LayerCount", len(options.Layers)) + return options.Tag, nil +} + +// writeImageLayerFile writes a single image layer's tar contents to destPath, sourcing the bytes +// from either the layer's on-disk Source file or its base64-encoded RawContents. +func writeImageLayerFile(destPath string, layer *apiv1.ImageLayer) error { + destFile, openErr := usvc_io.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if openErr != nil { + return openErr + } + defer destFile.Close() + + if layer.Source != "" { + sourceFile, sourceErr := usvc_io.OpenFile(layer.Source, os.O_RDONLY, 0) + if sourceErr != nil { + return fmt.Errorf("opening layer source file %q: %w", layer.Source, sourceErr) + } + defer sourceFile.Close() + + if _, copyErr := io.Copy(destFile, sourceFile); copyErr != nil { + return fmt.Errorf("copying layer source file %q: %w", layer.Source, copyErr) + } + return nil + } + + decoded, decodeErr := base64.StdEncoding.DecodeString(layer.RawContents) + if decodeErr != nil { + return fmt.Errorf("decoding base64 rawContents: %w", decodeErr) + } + if _, writeErr := destFile.Write(decoded); writeErr != nil { + return writeErr + } + return nil +} + +func (wco *WslcCliOrchestrator) WatchContainers(sink chan<- containers.EventMessage) (*pubsub.Subscription[containers.EventMessage], error) { + sub := wco.containerEvtWatcher.Subscribe(sink) + return sub, nil +} + +func (wco *WslcCliOrchestrator) CaptureContainerLogs(ctx context.Context, container string, stdout usvc_io.WriteSyncerCloser, stderr usvc_io.WriteSyncerCloser, options containers.StreamContainerLogsOptions) error { + args := []string{"container", "logs"} + args = options.Apply(args) + args = append(args, container) + + cmd := makeWslcCommand(args...) + + exitCh, err := wco.streamWslcCommand(ctx, "CaptureContainerLogs", cmd, stdout, stderr, streamCommandOptionUseWatcher) + if err != nil { + return err + } + + go func() { + // Wait for the command to finish and clean up any resources + exitErr := <-exitCh + if exitErr != nil && !errors.Is(exitErr, context.Canceled) && !errors.Is(exitErr, context.DeadlineExceeded) { + wco.log.Error(err, "Capturing container logs failed", "Container", container) + } + + if stdOutCloseErr := stdout.Close(); stdOutCloseErr != nil { + wco.log.Error(stdOutCloseErr, "Closing stdout log destination failed", "Container", container) + } + if stdErrCloseErr := stderr.Close(); stdErrCloseErr != nil { + wco.log.Error(stdErrCloseErr, "Closing stderr log destination failed", "Container", container) + } + }() + + return nil +} + +func (wco *WslcCliOrchestrator) WatchNetworks(sink chan<- containers.EventMessage) (*pubsub.Subscription[containers.EventMessage], error) { + sub := wco.networkEvtWatcher.Subscribe(sink) + return sub, nil +} + +func (wco *WslcCliOrchestrator) CreateNetwork(ctx context.Context, options containers.CreateNetworkOptions) (string, error) { + args := []string{"network", "create"} + + // TODO: wslc network create does not support enabling IPv6. + if options.IPv6 { + wco.log.Info("wslc does not support creating IPv6 networks; creating an IPv4 network instead", "Network", options.Name) + } + + for key, value := range options.Labels { + args = append(args, "--label", fmt.Sprintf("%s=%s", key, value)) + } + + args = append(args, options.Name) + + cmd := makeWslcCommand(args...) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "CreateNetwork", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + return "", errors.Join(err, normalizeCliErrors(errBuf, newNetworkAlreadyExistsErrorMatch.MaxObjects(1))) + } + + return asId(outBuf) +} + +func (wco *WslcCliOrchestrator) RemoveNetworks(ctx context.Context, options containers.RemoveNetworksOptions) ([]string, error) { + if len(options.Networks) == 0 { + return nil, fmt.Errorf("must specify at least one network") + } + + args := []string{"network", "remove"} + if options.Force { + args = append(args, "--force") + } + args = append(args, options.Networks...) + + cmd := makeWslcCommand(args...) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "RemoveNetworks", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + err = errors.Join(err, normalizeCliErrors(errBuf, newNetworkNotFoundErrorMatch.MaxObjects(len(options.Networks)))) + } + + nonEmpty := slices.NonEmpty[byte](bytes.Split(outBuf.Bytes(), osutil.LF())) + removed := slices.Map[string](nonEmpty, func(bs []byte) string { return string(bs) }) + + if len(removed) < len(options.Networks) { + err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all networks were removed, expected %d but got %d", len(options.Networks), len(removed)))) + } + + return removed, err +} + +func (wco *WslcCliOrchestrator) InspectNetworks(ctx context.Context, options containers.InspectNetworksOptions) ([]containers.InspectedNetwork, error) { + if len(options.Networks) == 0 { + return nil, fmt.Errorf("must specify at least one network") + } + + cmd := makeWslcCommand(append([]string{"network", "inspect"}, options.Networks...)...) + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "InspectNetworks", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + err = errors.Join(err, normalizeCliErrors(errBuf, newNetworkNotFoundErrorMatch.MaxObjects(len(options.Networks)))) + } + + inspectedNetworks, unmarshalErr := asObjects(outBuf, unmarshalNetwork) + err = errors.Join(err, unmarshalErr) + + if len(inspectedNetworks) < len(options.Networks) { + err = errors.Join(err, errors.Join(containers.ErrIncomplete, fmt.Errorf("not all networks were inspected, expected %d but got %d", len(options.Networks), len(inspectedNetworks)))) + } + + return inspectedNetworks, err +} + +func (wco *WslcCliOrchestrator) ConnectNetwork(_ context.Context, options containers.ConnectNetworkOptions) error { + // TODO: wslc cannot connect a running container to a network; networks can only be attached + // at container creation time via --network/--network-alias. Report the connection as already + // existing so callers (which treat ErrAlreadyExists as success) do not retry indefinitely. + wco.log.V(1).Info("wslc does not support connecting a running container to a network", "Network", options.Network, "Container", options.Container) + return errors.Join(containers.ErrAlreadyExists, fmt.Errorf("wslc attaches networks only at container creation time")) +} + +func (wco *WslcCliOrchestrator) DisconnectNetwork(_ context.Context, options containers.DisconnectNetworkOptions) error { + // TODO: wslc cannot disconnect a container from a network. Report the network as not found so + // callers (which treat ErrNotFound as success) consider the container disconnected. + wco.log.V(1).Info("wslc does not support disconnecting a container from a network", "Network", options.Network, "Container", options.Container) + return errors.Join(containers.ErrNotFound, fmt.Errorf("wslc does not support disconnecting a container from a network")) +} + +func (wco *WslcCliOrchestrator) ListNetworks(ctx context.Context, options containers.ListNetworksOptions) ([]containers.ListedNetwork, error) { + // wslc network list does not support --filter, so label filtering is performed in Go below. + cmd := makeWslcCommand("network", "list", "--format", "json") + + outBuf, errBuf, err := wco.runBufferedWslcCommand(ctx, "ListNetworks", cmd, nil, nil, ordinaryWslcCommandTimeout) + if err != nil { + return nil, errors.Join(err, normalizeCliErrors(errBuf)) + } + + listedNetworks, err := asObjects(outBuf, unmarshalListedNetwork) + if err != nil { + return nil, err + } + + if len(options.Filters.LabelFilters) == 0 { + return listedNetworks, nil + } + + // wslc network list output does not include labels, so inspect the networks to obtain labels + // and filter the result set in Go. + names := slices.Map[string](listedNetworks, func(n containers.ListedNetwork) string { return n.Name }) + inspected, inspectErr := wco.InspectNetworks(ctx, containers.InspectNetworksOptions{Networks: names}) + if inspectErr != nil && len(inspected) == 0 { + return nil, inspectErr + } + + labelsByName := make(map[string]map[string]string, len(inspected)) + for i := range inspected { + labelsByName[inspected[i].Name] = inspected[i].Labels + } + + filtered := make([]containers.ListedNetwork, 0, len(listedNetworks)) + for i := range listedNetworks { + if networkMatchesLabelFilters(labelsByName[listedNetworks[i].Name], options.Filters.LabelFilters) { + network := listedNetworks[i] + network.Labels = labelsByName[network.Name] + filtered = append(filtered, network) + } + } + + return filtered, nil +} + +func (*WslcCliOrchestrator) DefaultNetworkName() string { + return "bridge" +} + +// NetworksAttachedAtCreation reports true: wslc can attach a container to networks only at creation +// time (via --network/--network-alias); it cannot connect or disconnect a running container. +func (*WslcCliOrchestrator) NetworksAttachedAtCreation() bool { + return true +} + +// TODO: wslc has no `events` command, so container and network events cannot be streamed. +// The watchers block until cancellation so the subscription set treats them as alive while +// never delivering any events. +func (*WslcCliOrchestrator) doWatchContainers(watcherCtx context.Context, _ *pubsub.SubscriptionSet[containers.EventMessage]) { + <-watcherCtx.Done() +} + +func (*WslcCliOrchestrator) doWatchNetworks(watcherCtx context.Context, _ *pubsub.SubscriptionSet[containers.EventMessage]) { + <-watcherCtx.Done() +} + +type streamWslcCommandOption uint32 + +const ( + streamCommandOptionNone streamWslcCommandOption = 0 + streamCommandOptionUseWatcher streamWslcCommandOption = 1 +) + +func (wco *WslcCliOrchestrator) streamWslcCommand( + ctx context.Context, + commandName string, + cmd *exec.Cmd, + stdOutWriter io.Writer, + stdErrWriter io.Writer, + opts streamWslcCommandOption, +) (<-chan error, error) { + cmd.Stdout = stdOutWriter + cmd.Stderr = stdErrWriter + + exitCh := make(chan error) + exitHandler := func(_ process.Pid_t, exitCode int32, err error) { + defer close(exitCh) + if err != nil { + exitCh <- err + } + + if exitCode != 0 { + exitCh <- fmt.Errorf("wslc command '%s' returned with non-zero exit code %d", commandName, exitCode) + } + } + + wco.log.V(1).Info("Running wslc command", "Command", cmd.String()) + pid, startTime, startWaitForProcessExit, err := wco.executor.StartProcess(ctx, cmd, process.ProcessExitHandlerFunc(exitHandler), process.CreationFlagsNone, nil) + if err != nil { + close(exitCh) + return nil, errors.Join(err, fmt.Errorf("failed to start wslc command '%s'", commandName)) + } + + if opts&streamCommandOptionUseWatcher != 0 { + dcpproc.RunProcessWatcher(wco.executor, pid, startTime, wco.log) + } + + startWaitForProcessExit() + + return exitCh, nil +} + +func (wco *WslcCliOrchestrator) runBufferedWslcCommand( + ctx context.Context, + commandName string, + cmd *exec.Cmd, + stdOutWriteCloser io.WriteCloser, + stdErrWriteCloser io.WriteCloser, + timeout time.Duration, +) (*bytes.Buffer, *bytes.Buffer, error) { + effectiveCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + outBuf := new(bytes.Buffer) + var stdOutWriter io.Writer + if stdOutWriteCloser != nil { + defer func() { _ = stdOutWriteCloser.Close() }() + stdOutWriter = io.MultiWriter(stdOutWriteCloser, outBuf) + } else { + stdOutWriter = outBuf + } + + errBuf := new(bytes.Buffer) + var stdErrWriter io.Writer + if stdErrWriteCloser != nil { + defer func() { _ = stdErrWriteCloser.Close() }() + stdErrWriter = io.MultiWriter(stdErrWriteCloser, errBuf) + } else { + stdErrWriter = errBuf + } + + exitCh, err := wco.streamWslcCommand(effectiveCtx, commandName, cmd, stdOutWriter, stdErrWriter, streamCommandOptionNone) + if err == nil { + // If we successfully started running, wait for the command to finish + exitErr := <-exitCh + if exitErr != nil { + err = exitErr + } + } + + if err != nil { + stderr := "" + stdout := "" + if errBuf.Len() > 0 { + stderr = errBuf.String() + } + if outBuf.Len() > 0 { + stdout = outBuf.String() + } + + return outBuf, errBuf, fmt.Errorf("%w: command output: Stdout: '%s' Stderr: '%s'", err, stdout, stderr) + } + + return outBuf, errBuf, nil +} + +func makeWslcCommand(args ...string) *exec.Cmd { + cmd := exec.Command("wslc", args...) + // exec.Command resolves cmd.Path via LookPath but leaves cmd.Args[0] as + // the original "wslc" string. Align Args[0] with the resolved Path so + // downstream consumers and child-process argv[0] observers see a + // consistent, fully-qualified command name. + if cmd.Path != "" { + cmd.Args[0] = cmd.Path + } + return cmd +} + +func (wco *WslcCliOrchestrator) MakeCommand(args ...string) *exec.Cmd { + return makeWslcCommand(args...) +} + +func (wco *WslcCliOrchestrator) RunBufferedCommand(ctx context.Context, opName string, cmd *exec.Cmd, stdout io.WriteCloser, stderr io.WriteCloser, timeout time.Duration) (*bytes.Buffer, *bytes.Buffer, error) { + return wco.runBufferedWslcCommand(ctx, opName, cmd, stdout, stderr, timeout) +} + +// wslc returns a JSON array for the commands that we parse, so unmarshal the entire output as an array of values. +func asObjects[T any, V any](b *bytes.Buffer, unmarshalFn func(*V, *T) error) ([]T, error) { + if b == nil { + return nil, fmt.Errorf("the wslc command timed out without returning any data") + } + + retval := []T{} + + var unmarshalRaw []V + rawBytes := b.Bytes() + err := json.Unmarshal(rawBytes, &unmarshalRaw) + if err != nil { + return nil, err + } + + for i := range unmarshalRaw { + rawValue := &unmarshalRaw[i] + + var obj T + err = unmarshalFn(rawValue, &obj) + if err != nil { + return nil, err + } + + retval = append(retval, obj) + } + + return retval, nil +} + +func asId(b *bytes.Buffer) (string, error) { + if b == nil { + return "", fmt.Errorf("the wslc command timed out without returning object identifier") + } + + chunks := slices.NonEmpty[byte](slices.Map[[]byte, []byte](bytes.Split(b.Bytes(), osutil.LF()), bytes.TrimSpace)) + if len(chunks) != 1 { + return "", fmt.Errorf("command output does not contain a single identifier (it is '%s')", b.String()) + } + return string(chunks[0]), nil +} + +func parseDiagnostics(data []byte) (containers.ContainerDiagnostics, error) { + if data == nil { + return containers.ContainerDiagnostics{}, fmt.Errorf("the wslc command timed out without returning diagnostics data") + } + + match := versionRegEx.FindSubmatch(data) + if match == nil { + return containers.ContainerDiagnostics{}, fmt.Errorf("could not parse wslc version from output: %q", string(data)) + } + + version := string(match[1]) + // wslc is a single binary, so the client and server versions are the same. + return containers.ContainerDiagnostics{ + ClientVersion: version, + ServerVersion: version, + }, nil +} + +func networkMatchesLabelFilters(labels map[string]string, filters []containers.LabelFilter) bool { + for _, filter := range filters { + value, ok := labels[filter.Key] + if !ok { + return false + } + if filter.Value != "" && value != filter.Value { + return false + } + } + return true +} + +func unmarshalVolume(wvi *wslcInspectedVolume, vol *containers.InspectedVolume) error { + vol.Name = wvi.Name + vol.Driver = wvi.Driver + vol.CreatedAt = wvi.CreatedAt + vol.Labels = wvi.Labels + + return nil +} + +func unmarshalImage(wii *wslcInspectedImage, ic *containers.InspectedImage) error { + ic.Id = wii.Id + ic.Labels = wii.Config.Labels + ic.Tags = wii.RepoTags + if len(wii.RepoDigests) > 0 { + ic.Digest = wii.RepoDigests[0] + } + + return nil +} + +func unmarshalListedContainer(wlc *wslcListedContainer, lc *containers.ListedContainer) error { + lc.Id = wlc.Id + lc.Name = wlc.Name + lc.Image = wlc.Image + lc.Status = wslcStateToStatus(wlc.State) + + return nil +} + +func unmarshalContainer(wci *wslcInspectedContainer, ic *containers.InspectedContainer) error { + ic.Id = wci.Id + ic.Name = wci.Name + ic.Image = wci.Image + ic.CreatedAt = wci.Created + ic.StartedAt = wci.State.StartedAt + ic.FinishedAt = wci.State.FinishedAt + ic.Status = wci.State.Status + ic.ExitCode = wci.State.ExitCode + ic.Error = wci.State.Error + + ic.Mounts = make([]apiv1.VolumeMount, len(wci.Mounts)) + for i, mount := range wci.Mounts { + source := mount.Source + if mount.Type == apiv1.NamedVolumeMount { + source = mount.Name + } + + ic.Mounts[i] = apiv1.VolumeMount{ + Type: mount.Type, + Source: source, + Target: mount.Destination, + ReadOnly: !mount.ReadWrite, + } + } + + // wslc reports the container port bindings at the top level of the inspect output. + ic.Ports = make(containers.InspectedContainerPortMapping) + for portAndProtocol, portBindings := range wci.Ports { + if len(portAndProtocol) == 0 || len(portBindings) == 0 { + continue // Skip ports that are published but not mapped to host. + } + ic.Ports[portAndProtocol] = portBindings + } + + ic.Env = make(map[string]string) + for _, envVar := range wci.Config.Env { + parts := strings.SplitN(envVar, "=", 2) + if len(parts) > 1 { + ic.Env[parts[0]] = parts[1] + } else if len(parts) == 1 { + ic.Env[parts[0]] = "" + } + } + + ic.Args = append(ic.Args, wci.Config.Entrypoint...) + ic.Args = append(ic.Args, wci.Config.Cmd...) + + // wslc inspect output does not include a network ID, so it is left empty. + for name, network := range wci.NetworkSettings.Networks { + ic.Networks = append( + ic.Networks, + containers.InspectedContainerNetwork{ + Name: name, + IPAddress: network.IPAddress, + Gateway: network.Gateway, + MacAddress: network.MacAddress, + Aliases: network.Aliases, + }, + ) + } + + ic.Labels = wci.Labels + + return nil +} + +func unmarshalNetwork(wcn *wslcInspectedNetwork, net *containers.InspectedNetwork) error { + // wslc CLI accepts only network names (not the hash Id) for inspect/remove/connect, so the + // name is used as the DCP-facing network Id to keep all subsequent operations addressable. + net.Id = wcn.Name + net.Name = wcn.Name + net.Driver = wcn.Driver + net.Scope = wcn.Scope + if net.Scope == "" { + net.Scope = "local" + } + net.Labels = wcn.Labels + net.Attachable = true + net.Internal = wcn.Internal + net.Ingress = false + for i := range wcn.IPAM.Config { + net.Subnets = append(net.Subnets, wcn.IPAM.Config[i].Subnet) + net.Gateways = append(net.Gateways, wcn.IPAM.Config[i].Gateway) + } + + return nil +} + +func unmarshalListedNetwork(wln *wslcListedNetwork, net *containers.ListedNetwork) error { + net.Name = wln.Name + // See unmarshalNetwork: wslc addresses networks by name, so the name is used as the Id. + net.ID = wln.Name + net.Driver = wln.Driver + + return nil +} + +// wslcStateToStatus maps the integer container state reported by `wslc container list` to the +// DCP container status. Confirmed values: 1=created, 2=running, 3=exited. +// TODO: wslc has no pause command and the integer values for paused/restarting/removing/dead +// states have not been observed; unknown values are reported as an empty status. +func wslcStateToStatus(state int) containers.ContainerStatus { + switch state { + case 1: + return containers.ContainerStatusCreated + case 2: + return containers.ContainerStatusRunning + case 3: + return containers.ContainerStatusExited + default: + return containers.ContainerStatus("") + } +} + +// wslcEntrypoint normalizes the container entrypoint, which may be reported either as a single +// string or as an array of strings, into an array of strings. +type wslcEntrypoint []string + +func (we *wslcEntrypoint) UnmarshalJSON(b []byte) error { + var maybeArray []string + arrayErr := json.Unmarshal(b, &maybeArray) + if arrayErr != nil { + var maybeString string + stringErr := json.Unmarshal(b, &maybeString) + if stringErr != nil { + return fmt.Errorf("error parsing container inspect: Entrypoint is neither a string nor an array of strings") + } + + // entrypoint was a string, normalize to an array of one value + *we = wslcEntrypoint{maybeString} + return nil + } + + // entrypoint is an array of strings + *we = wslcEntrypoint(maybeArray) + return nil +} + +// wslcInspectedVolume corresponds to data returned by `wslc volume inspect`. +type wslcInspectedVolume struct { + Name string `json:"Name"` + Driver string `json:"Driver,omitempty"` + CreatedAt time.Time `json:"CreatedAt,omitempty"` + Labels map[string]string `json:"Labels,omitempty"` +} + +// wslcInspectedImage corresponds to data returned by `wslc image inspect`. +type wslcInspectedImage struct { + Id string `json:"Id"` + Config wslcInspectedImageConfig `json:"Config,omitempty"` + RepoTags []string `json:"RepoTags,omitempty"` + RepoDigests []string `json:"RepoDigests,omitempty"` +} + +type wslcInspectedImageConfig struct { + Labels map[string]string `json:"Labels,omitempty"` +} + +// wslcListedContainer corresponds to data returned by `wslc container list --format json`. +type wslcListedContainer struct { + Id string `json:"Id"` + Name string `json:"Name,omitempty"` + Image string `json:"Image,omitempty"` + State int `json:"State,omitempty"` +} + +// wslcInspectedContainer corresponds to data returned by `wslc container inspect`. +// Unlike Docker/Podman, wslc reports the image, labels, and port bindings at the top level. +type wslcInspectedContainer struct { + Id string `json:"Id"` + Name string `json:"Name,omitempty"` + Image string `json:"Image,omitempty"` + Labels map[string]string `json:"Labels,omitempty"` + Created time.Time `json:"Created,omitempty"` + Config wslcInspectedContainerConfig `json:"Config,omitempty"` + State wslcInspectedContainerState `json:"State,omitempty"` + Mounts []wslcInspectedContainerMount `json:"Mounts,omitempty"` + Ports containers.InspectedContainerPortMapping `json:"Ports,omitempty"` + NetworkSettings wslcInspectedContainerNetworkSettings `json:"NetworkSettings,omitempty"` +} + +type wslcInspectedContainerConfig struct { + Env []string `json:"Env,omitempty"` + Cmd []string `json:"Cmd,omitempty"` + Entrypoint wslcEntrypoint `json:"Entrypoint,omitempty"` +} + +type wslcInspectedContainerState struct { + Status containers.ContainerStatus `json:"Status,omitempty"` + StartedAt time.Time `json:"StartedAt,omitempty"` + FinishedAt time.Time `json:"FinishedAt,omitempty"` + ExitCode int32 `json:"ExitCode,omitempty"` + Error string `json:"Error,omitempty"` +} + +type wslcInspectedContainerMount struct { + Type apiv1.VolumeMountType `json:"Type,omitempty"` + Name string `json:"Name,omitempty"` + Source string `json:"Source,omitempty"` + Destination string `json:"Destination,omitempty"` + ReadWrite bool `json:"RW,omitempty"` +} + +type wslcInspectedContainerNetworkSettings struct { + Networks map[string]wslcInspectedContainerNetworkSettingsNetwork `json:"Networks,omitempty"` +} + +type wslcInspectedContainerNetworkSettingsNetwork struct { + IPAddress string `json:"IPAddress,omitempty"` + Gateway string `json:"Gateway,omitempty"` + MacAddress string `json:"MacAddress,omitempty"` + Aliases []string `json:"Aliases,omitempty"` +} + +// wslcInspectedNetwork corresponds to data returned by `wslc network inspect`. +type wslcInspectedNetwork struct { + Id string `json:"Id"` + Name string `json:"Name"` + Driver string `json:"Driver,omitempty"` + Internal bool `json:"Internal,omitempty"` + Scope string `json:"Scope,omitempty"` + Labels map[string]string `json:"Labels,omitempty"` + IPAM wslcInspectedNetworkIPAM `json:"IPAM,omitempty"` +} + +type wslcInspectedNetworkIPAM struct { + Driver string `json:"Driver,omitempty"` + Config []wslcInspectedNetworkIPAMConfig `json:"Config,omitempty"` +} + +type wslcInspectedNetworkIPAMConfig struct { + Subnet string `json:"Subnet,omitempty"` + Gateway string `json:"Gateway,omitempty"` +} + +// wslcListedNetwork corresponds to data returned by `wslc network list --format json`. +type wslcListedNetwork struct { + Name string `json:"Name"` + Id string `json:"Id"` + Driver string `json:"Driver,omitempty"` +} + +func normalizeCliErrors(errBuf *bytes.Buffer, errorMatches ...containers.ErrorMatch) error { + errorMatches = append(errorMatches, newWslcNotRunningErrorMatch) + return containers.NormalizeCliErrors(errBuf, errorMatches...) +} + +var _ containers.VolumeOrchestrator = (*WslcCliOrchestrator)(nil) +var _ containers.ImageOrchestrator = (*WslcCliOrchestrator)(nil) +var _ containers.ContainerOrchestrator = (*WslcCliOrchestrator)(nil) +var _ containers.NetworkOrchestrator = (*WslcCliOrchestrator)(nil) diff --git a/internal/wslc/cli_orchestrator_test.go b/internal/wslc/cli_orchestrator_test.go new file mode 100644 index 00000000..1cbdedfb --- /dev/null +++ b/internal/wslc/cli_orchestrator_test.go @@ -0,0 +1,621 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "bytes" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + apiv1 "github.com/microsoft/dcp/api/v1" + "github.com/microsoft/dcp/internal/containers" +) + +func TestInspectedRunningContainerDeserialization(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + _, err := b.WriteString(inspectedRunningContainer) + require.NoError(t, err) + + inspectedContainers, err := asObjects(&b, unmarshalContainer) + require.NoError(t, err) + require.Len(t, inspectedContainers, 1) + + ct := inspectedContainers[0] + + require.Equal(t, "2187976cbf06c74c0582a4bd83bcfe50e6d7c268b753dc81130062868bb39320", ct.Id) + require.Equal(t, "gleaming_scandinavian", ct.Name) + require.Equal(t, "mcr.microsoft.com/dotnet/aspire-dashboard:latest", ct.Image) + + expectedCreatedTime, err := time.Parse(time.RFC3339Nano, "2026-06-30T10:00:53.872669951Z") + require.NoError(t, err) + require.Equal(t, expectedCreatedTime, ct.CreatedAt) + + expectedStartedTime, err := time.Parse(time.RFC3339Nano, "2026-06-30T10:00:54.32029315Z") + require.NoError(t, err) + require.Equal(t, expectedStartedTime, ct.StartedAt) + + require.Equal(t, containers.ContainerStatusRunning, ct.Status) + require.EqualValues(t, 0, ct.ExitCode) + + // wslc reports port bindings at the top level of the inspect output. + require.Equal(t, containers.InspectedContainerPortMapping{ + "18888/tcp": []containers.InspectedContainerHostPortConfig{{HostIp: "127.0.0.1", HostPort: "18888"}}, + "18889/tcp": []containers.InspectedContainerHostPortConfig{{HostIp: "127.0.0.1", HostPort: "4317"}}, + }, ct.Ports) + + require.Equal(t, "1654", ct.Env["APP_UID"]) + require.Equal(t, []string{"dotnet", "/app/Aspire.Dashboard.dll"}, ct.Args) + + // wslc does not report a network ID, so it is left empty. + require.Equal(t, []containers.InspectedContainerNetwork{ + { + Name: "bridge", + IPAddress: "172.17.0.2", + Gateway: "172.17.0.1", + MacAddress: "02:42:ac:11:00:02", + Aliases: []string{}, + }, + }, ct.Networks) +} + +func TestInspectedExitedContainerDeserialization(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + _, err := b.WriteString(inspectedExitedContainer) + require.NoError(t, err) + + inspectedContainers, err := asObjects(&b, unmarshalContainer) + require.NoError(t, err) + require.Len(t, inspectedContainers, 1) + + ct := inspectedContainers[0] + require.Equal(t, containers.ContainerStatusExited, ct.Status) + require.EqualValues(t, 143, ct.ExitCode) + require.False(t, ct.FinishedAt.IsZero()) +} + +func TestListedContainerDeserialization(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + _, err := b.WriteString(listedContainers) + require.NoError(t, err) + + listed, err := asObjects(&b, unmarshalListedContainer) + require.NoError(t, err) + require.Len(t, listed, 3) + + require.Equal(t, containers.ContainerStatusCreated, listed[0].Status) + require.Equal(t, containers.ContainerStatusRunning, listed[1].Status) + require.Equal(t, containers.ContainerStatusExited, listed[2].Status) + + require.Equal(t, "gleaming_scandinavian", listed[1].Name) + require.Equal(t, "mcr.microsoft.com/dotnet/aspire-dashboard:latest", listed[1].Image) + require.Equal(t, "2187976cbf06c74c0582a4bd83bcfe50e6d7c268b753dc81130062868bb39320", listed[1].Id) +} + +func TestWslcStateToStatus(t *testing.T) { + t.Parallel() + + require.Equal(t, containers.ContainerStatusCreated, wslcStateToStatus(1)) + require.Equal(t, containers.ContainerStatusRunning, wslcStateToStatus(2)) + require.Equal(t, containers.ContainerStatusExited, wslcStateToStatus(3)) + require.Equal(t, containers.ContainerStatus(""), wslcStateToStatus(99)) +} + +func TestInspectedVolumeDeserialization(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + _, err := b.WriteString(inspectedVolume) + require.NoError(t, err) + + volumes, err := asObjects(&b, unmarshalVolume) + require.NoError(t, err) + require.Len(t, volumes, 1) + + vol := volumes[0] + require.Equal(t, "dcp-testvol", vol.Name) + require.Equal(t, "guest", vol.Driver) + expectedCreatedTime, err := time.Parse(time.RFC3339, "2026-06-30T11:31:20Z") + require.NoError(t, err) + require.Equal(t, expectedCreatedTime, vol.CreatedAt) +} + +func TestInspectedImageDeserialization(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + _, err := b.WriteString(inspectedImage) + require.NoError(t, err) + + images, err := asObjects(&b, unmarshalImage) + require.NoError(t, err) + require.Len(t, images, 1) + + img := images[0] + require.Equal(t, "sha256:a33c2e3a3c1f0b9e0e8c1f6f8b7a6d5c4b3a2918273645aef0123456789abcde", img.Id) + require.Equal(t, []string{"mcr.microsoft.com/dotnet/aspire-dashboard:latest"}, img.Tags) + require.Equal(t, "mcr.microsoft.com/dotnet/aspire-dashboard@sha256:1d584e3322bc9876543210fedcba0123456789abcdef0123456789abcdef0123", img.Digest) +} + +func TestInspectedNetworkDeserialization(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + _, err := b.WriteString(inspectedNetwork) + require.NoError(t, err) + + networks, err := asObjects(&b, unmarshalNetwork) + require.NoError(t, err) + require.Len(t, networks, 1) + + net := networks[0] + require.Equal(t, "dcp-testnet", net.Id) + require.Equal(t, "dcp-testnet", net.Name) + require.Equal(t, "bridge", net.Driver) + require.Equal(t, "local", net.Scope) + require.False(t, net.Internal) + require.True(t, net.Attachable) + require.Equal(t, []string{"172.18.0.0/16"}, net.Subnets) + require.Equal(t, []string{"172.18.0.1"}, net.Gateways) + require.Equal(t, map[string]string{"com.microsoft.wsl.network.managed": "true"}, net.Labels) +} + +func TestListedNetworkDeserialization(t *testing.T) { + t.Parallel() + + var b bytes.Buffer + _, err := b.WriteString(listedNetworks) + require.NoError(t, err) + + networks, err := asObjects(&b, unmarshalListedNetwork) + require.NoError(t, err) + require.Len(t, networks, 1) + + require.Equal(t, "dcp-testnet", networks[0].Name) + require.Equal(t, "dcp-testnet", networks[0].ID) + require.Equal(t, "bridge", networks[0].Driver) +} + +func TestParseDiagnostics(t *testing.T) { + t.Parallel() + + diag, err := parseDiagnostics([]byte("wslc 2.9.3.0\n")) + require.NoError(t, err) + require.Equal(t, "2.9.3.0", diag.ClientVersion) + require.Equal(t, "2.9.3.0", diag.ServerVersion) + + _, err = parseDiagnostics([]byte("unexpected output")) + require.Error(t, err) +} + +func TestApplyCreateContainerOptionsVolumeMounts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mount apiv1.VolumeMount + wantArgs []string + }{ + { + name: "named volume uses source:target", + mount: apiv1.VolumeMount{ + Type: apiv1.NamedVolumeMount, + Source: "myvolume", + Target: "/data", + }, + wantArgs: []string{"-v", "myvolume:/data"}, + }, + { + name: "anonymous volume uses target only", + mount: apiv1.VolumeMount{ + Type: apiv1.NamedVolumeMount, + Source: "", + Target: "/data", + }, + wantArgs: []string{"-v", "/data"}, + }, + { + name: "named volume readonly appends ro", + mount: apiv1.VolumeMount{ + Type: apiv1.NamedVolumeMount, + Source: "myvolume", + Target: "/data", + ReadOnly: true, + }, + wantArgs: []string{"-v", "myvolume:/data:ro"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + options := containers.CreateContainerOptions{} + options.VolumeMounts = []apiv1.VolumeMount{tc.mount} + args := applyCreateContainerOptions([]string{}, options) + require.Equal(t, tc.wantArgs, args) + }) + } +} + +func TestApplyCreateContainerOptionsPorts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + port apiv1.ContainerPort + wantArgs []string + }{ + { + name: "uppercase protocol is lowercased for wslc", + port: apiv1.ContainerPort{ + HostPort: 5000, + ContainerPort: 5000, + Protocol: apiv1.TCP, + }, + wantArgs: []string{"-p", "127.0.0.1:5000:5000/tcp"}, + }, + { + name: "udp protocol is lowercased", + port: apiv1.ContainerPort{ + HostPort: 5300, + ContainerPort: 53, + Protocol: apiv1.UDP, + }, + wantArgs: []string{"-p", "127.0.0.1:5300:53/udp"}, + }, + { + name: "no protocol omits the suffix", + port: apiv1.ContainerPort{ + HostPort: 8080, + ContainerPort: 80, + }, + wantArgs: []string{"-p", "127.0.0.1:8080:80"}, + }, + { + name: "host ip is honored", + port: apiv1.ContainerPort{ + HostIP: "0.0.0.0", + HostPort: 8080, + ContainerPort: 80, + Protocol: apiv1.TCP, + }, + wantArgs: []string{"-p", "0.0.0.0:8080:80/tcp"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + options := containers.CreateContainerOptions{} + options.Ports = []apiv1.ContainerPort{tc.port} + args := applyCreateContainerOptions([]string{}, options) + require.Equal(t, tc.wantArgs, args) + }) + } +} + +func TestApplyCreateContainerOptionsNetworks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + options containers.CreateContainerOptions + wantArgs []string + }{ + { + name: "single network with aliases", + options: containers.CreateContainerOptions{Network: "bridge", NetworkAliases: []string{"alias1", "alias2"}}, + wantArgs: []string{"--network", "bridge", "--network-alias", "alias1", "--network-alias", "alias2"}, + }, + { + name: "creation networks take precedence and support multiple networks", + options: containers.CreateContainerOptions{ + Network: "ignored", + CreationNetworks: []containers.CreationNetwork{ + {Name: "net1", Aliases: []string{"db"}}, + {Name: "net2"}, + }, + }, + wantArgs: []string{"--network", "net1", "--network-alias", "db", "--network", "net2"}, + }, + { + name: "no network produces no args", + options: containers.CreateContainerOptions{}, + wantArgs: []string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + args := applyCreateContainerOptions([]string{}, tc.options) + require.Equal(t, tc.wantArgs, args) + }) + } +} + +func TestNormalizeCliErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stderr string + match containers.ErrorMatch + wantErr error + }{ + { + name: "container not found", + stderr: "Container 'abc123' not found.", + match: newContainerNotFoundErrorMatch, + wantErr: containers.ErrNotFound, + }, + { + name: "network not found", + stderr: "Network not found: 'dcp-testnet'", + match: newNetworkNotFoundErrorMatch, + wantErr: containers.ErrNotFound, + }, + { + name: "volume not found", + stderr: "Volume not found: 'dcp-testvol'", + match: newVolumeNotFoundErrorMatch, + wantErr: containers.ErrNotFound, + }, + { + name: "image not found on inspect", + stderr: "Image 'doesnotexist123' not found.", + match: imageNotFoundErrorMatch, + wantErr: containers.ErrNotFound, + }, + { + name: "image not found on pull", + stderr: "pull access denied for doesnotexist123, repository does not exist\nError code: WSLC_E_IMAGE_NOT_FOUND", + match: imageNotFoundErrorMatch, + wantErr: containers.ErrNotFound, + }, + { + name: "network already exists", + stderr: "Cannot create a file when that file already exists. (ERROR_ALREADY_EXISTS)", + match: newNetworkAlreadyExistsErrorMatch, + wantErr: containers.ErrAlreadyExists, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + buf := bytes.NewBufferString(tc.stderr) + err := normalizeCliErrors(buf, tc.match) + require.ErrorIs(t, err, tc.wantErr) + }) + } +} + +func TestTarMissingDetection(t *testing.T) { + t.Parallel() + + matching := []string{ + "exec: \"tar\": executable file not found in $PATH", + "tar: not found", + "sh: tar: command not found", + "/bin/sh: tar: no such file or directory", + } + for _, s := range matching { + require.True(t, tarMissingRegEx.MatchString(s), "expected tar-missing match for: %s", s) + } + + nonMatching := []string{ + "Container 'abc123' not found.", + "tar archive extracted successfully", + "permission denied", + } + for _, s := range nonMatching { + require.False(t, tarMissingRegEx.MatchString(s), "did not expect tar-missing match for: %s", s) + } +} + +func TestBuildCreateFilesError(t *testing.T) { + t.Parallel() + + execErr := fmt.Errorf("exit status 1") + + t.Run("missing tar adds diagnostic hint", func(t *testing.T) { + t.Parallel() + err := buildCreateFilesError("web-1", "exec: \"tar\": executable file not found in $PATH", execErr) + require.Error(t, err) + require.ErrorIs(t, err, execErr) + require.NotErrorIs(t, err, containers.ErrNotFound) + require.Contains(t, err.Error(), "web-1") + require.Contains(t, err.Error(), "`tar` binary on PATH") + }) + + t.Run("container not found is surfaced without tar hint", func(t *testing.T) { + t.Parallel() + err := buildCreateFilesError("web-1", "Container 'web-1' not found.", execErr) + require.Error(t, err) + require.ErrorIs(t, err, containers.ErrNotFound) + require.NotContains(t, err.Error(), "`tar` binary on PATH") + }) + + t.Run("other failures do not get tar hint", func(t *testing.T) { + t.Parallel() + err := buildCreateFilesError("web-1", "permission denied", execErr) + require.Error(t, err) + require.ErrorIs(t, err, execErr) + require.NotContains(t, err.Error(), "`tar` binary on PATH") + }) +} + +func TestWriteImageLayerFile(t *testing.T) { + t.Parallel() + + t.Run("writes base64 RawContents", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + dest := filepath.Join(dir, "layer0.tar") + payload := []byte("layer-contents") + + layer := &apiv1.ImageLayer{RawContents: base64.StdEncoding.EncodeToString(payload)} + require.NoError(t, writeImageLayerFile(dest, layer)) + + written, err := os.ReadFile(dest) + require.NoError(t, err) + require.Equal(t, payload, written) + }) + + t.Run("copies from Source file", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + source := filepath.Join(dir, "source.tar") + payload := []byte("source-layer-contents") + require.NoError(t, os.WriteFile(source, payload, 0600)) + + dest := filepath.Join(dir, "layer0.tar") + layer := &apiv1.ImageLayer{Source: source} + require.NoError(t, writeImageLayerFile(dest, layer)) + + written, err := os.ReadFile(dest) + require.NoError(t, err) + require.Equal(t, payload, written) + }) + + t.Run("invalid base64 returns error", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + dest := filepath.Join(dir, "layer0.tar") + + layer := &apiv1.ImageLayer{RawContents: "not-valid-base64!!!"} + require.Error(t, writeImageLayerFile(dest, layer)) + }) + + t.Run("missing Source file returns error", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + dest := filepath.Join(dir, "layer0.tar") + + layer := &apiv1.ImageLayer{Source: filepath.Join(dir, "does-not-exist.tar")} + require.Error(t, writeImageLayerFile(dest, layer)) + }) +} + +const inspectedRunningContainer = ` +[ + { + "Config": { + "Cmd": null, + "Entrypoint": ["dotnet", "/app/Aspire.Dashboard.dll"], + "Env": ["PATH=/usr/local/sbin:/usr/local/bin", "APP_UID=1654"], + "User": "1654", + "WorkingDir": "/app" + }, + "Created": "2026-06-30T10:00:53.872669951Z", + "HostConfig": { "Memory": 0, "NanoCpus": 0, "NetworkMode": "bridge", "Ulimits": [] }, + "Id": "2187976cbf06c74c0582a4bd83bcfe50e6d7c268b753dc81130062868bb39320", + "Image": "mcr.microsoft.com/dotnet/aspire-dashboard:latest", + "Labels": {}, + "Mounts": [], + "Name": "gleaming_scandinavian", + "NetworkSettings": { + "Networks": { + "bridge": { + "Aliases": [], + "Gateway": "172.17.0.1", + "IPAddress": "172.17.0.2", + "IPPrefixLen": 16, + "MacAddress": "02:42:ac:11:00:02" + } + } + }, + "Ports": { + "18888/tcp": [{ "HostIp": "127.0.0.1", "HostPort": "18888" }], + "18889/tcp": [{ "HostIp": "127.0.0.1", "HostPort": "4317" }] + }, + "State": { + "ExitCode": 0, + "FinishedAt": "0001-01-01T00:00:00Z", + "Running": true, + "StartedAt": "2026-06-30T10:00:54.32029315Z", + "Status": "running" + } + } +]` + +const inspectedExitedContainer = ` +[ + { + "Config": { "Cmd": null, "Entrypoint": ["dotnet"], "Env": [], "User": "", "WorkingDir": "/app" }, + "Created": "2026-06-30T10:00:53.872669951Z", + "Id": "2187976cbf06c74c0582a4bd83bcfe50e6d7c268b753dc81130062868bb39320", + "Image": "mcr.microsoft.com/dotnet/aspire-dashboard:latest", + "Labels": {}, + "Mounts": [], + "Name": "gleaming_scandinavian", + "NetworkSettings": { "Networks": {} }, + "Ports": {}, + "State": { + "ExitCode": 143, + "FinishedAt": "2026-06-30T11:29:12.123456789Z", + "Running": false, + "StartedAt": "2026-06-30T10:00:54.32029315Z", + "Status": "exited" + } + } +]` + +const listedContainers = ` +[ + { "CreatedAt": 1782813000, "Id": "aaa", "Image": "img:created", "Name": "created_one", "Ports": [], "State": 1, "StateChangedAt": 1782813001 }, + { "CreatedAt": 1782813653, "Id": "2187976cbf06c74c0582a4bd83bcfe50e6d7c268b753dc81130062868bb39320", "Image": "mcr.microsoft.com/dotnet/aspire-dashboard:latest", "Name": "gleaming_scandinavian", "Ports": [{ "BindingAddress": "127.0.0.1", "ContainerPort": 18888, "HostPort": 18888, "Protocol": 6 }], "State": 2, "StateChangedAt": 1782813654 }, + { "CreatedAt": 1782813100, "Id": "ccc", "Image": "img:exited", "Name": "exited_one", "Ports": [], "State": 3, "StateChangedAt": 1782813200 } +]` + +const inspectedVolume = ` +[ + { "CreatedAt": "2026-06-30T11:31:20Z", "Driver": "guest", "DriverOpts": {}, "Labels": {}, "Name": "dcp-testvol", "Status": null } +]` + +const inspectedImage = ` +[ + { + "Architecture": "amd64", + "Config": { "Cmd": null, "Entrypoint": ["dotnet"], "Env": [], "Labels": null }, + "Created": "2026-06-30T00:00:00Z", + "Id": "sha256:a33c2e3a3c1f0b9e0e8c1f6f8b7a6d5c4b3a2918273645aef0123456789abcde", + "Os": "linux", + "RepoDigests": ["mcr.microsoft.com/dotnet/aspire-dashboard@sha256:1d584e3322bc9876543210fedcba0123456789abcdef0123456789abcdef0123"], + "RepoTags": ["mcr.microsoft.com/dotnet/aspire-dashboard:latest"], + "Size": 211401250 + } +]` + +const inspectedNetwork = ` +[ + { + "Driver": "bridge", + "IPAM": { "Config": [{ "Gateway": "172.18.0.1", "Subnet": "172.18.0.0/16" }], "Driver": "default" }, + "Id": "966ca82f0e0d1c2b3a4958677685949302010fedcba98765432100fedcba9876", + "Internal": false, + "Labels": { "com.microsoft.wsl.network.managed": "true" }, + "Name": "dcp-testnet", + "Scope": "local" + } +]` + +const listedNetworks = ` +[ + { "Driver": "bridge", "Id": "966ca82f0e0d1c2b3a4958677685949302010fedcba98765432100fedcba9876", "Name": "dcp-testnet" } +]`