diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d9a7597..b81f55f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,10 +181,51 @@ Not all tests are run by default. A small subset of tests aren't suitable for ru | --- | --------- | | `DCP_TEST_ENABLE_ADVANCED_NETWORKING` | Set to true to enable advanced networking tests such as those that require access to all network interfaces and try to open ports that are accessible to requests originating from outside of the machine, or tests that evaluate network performance against a baseline (these are unreliable on CI machines). | | `DCP_TEST_ENABLE_ADVANCED_CERTIFICATES`| Set to true to enable advanced certificate file tests such as those that require openssl installed to verify behavior. | -| `DCP_TEST_ENABLE_TRUE_CONTAINER_ORCHESTRATOR` | Set to true to enable tests that require real container orchestrator (Docker or Podman). | +| `DCP_TEST_ENABLE_TRUE_CONTAINER_ORCHESTRATOR` | Set to true to enable tests that require a real container orchestrator (Docker, Podman, or WSLC). | > You may need to install [Azure Artifacts Credentials Provider](https://github.com/microsoft/artifacts-credprovider#azure-artifacts-credential-provider) to be able to build some of the test artifacts for tests in this set. +### WSLC container runtime (experimental) + +On Windows, DCP can use the native Windows Subsystem for Linux container CLI, `wslc`, from PATH. Select it explicitly with `--container-runtime wslc`, for example: + +```powershell +go run .\cmd\dcp info --container-runtime wslc --diagnostics +``` + +Automatic selection prefers healthy Docker, then healthy Podman, then healthy WSLC. WSLC can therefore be selected automatically on a machine where it is the only healthy runtime. DCP uses WSLC's default session and leaves its lifecycle and configuration to WSLC; it does not create or terminate sessions. + +`ContainerHost()` returns an empty string when a runtime does not provide a default container-to-host address. For WSLC, `dcp info` preserves the `hostName` field with an empty string. This is an unsupported addressing capability, not an unhealthy runtime. Consumers must handle that value rather than assume `host.docker.internal` exists on WSLC-only machines. + +The CLI baseline for the following limitations is **WSLC 2.9.11.0**. A missing CLI option does not imply that the underlying WSL runtime lacks the corresponding capability. + +| Area | CLI limitation | DCP behavior | +| --- | --- | --- | +| Native events | No container or network event-stream command. | Watch calls report unsupported operations. WSLC remains experimental until native watches are implemented; no polling or synthetic events are substituted. | +| Image-layer builds | Tar build contexts on stdin and `build --quiet` are unavailable. | Stage a restricted temporary directory, reuse shared layer generation, build through the native CLI, and obtain the image ID through `--iidfile`. Docker/Podman keep their streaming path. | +| Build platform | `build --platform` is rejected. | Default/native builds work; unsupported platform requests fail explicitly. | +| Restart policy | `create --restart` is rejected. | Empty/`no` policy is supported; non-default requests fail explicitly. | +| Health-check start interval | `--health-start-interval` is rejected. | Supported health settings are retained; unsupported requested settings fail explicitly. | +| IPv6 networks | `network create --ipv6` is rejected. | IPv6 requests are not silently converted to IPv4-only networks. | +| Forced disconnect | `network disconnect --force` is rejected. | Attempt ordinary disconnect and verify detachment, including stopped-container configuration. Report failure if the requested result cannot be verified; stale-endpoint force parity is not assumed. | +| Raw capabilities | `--cap-add NET_RAW` is rejected. | Raw runtime arguments are not silently stripped. The tunnel test's optional ping/debug argument is omitted for WSLC, without removing its TCP assertions. | + +The adapter normalizes native CLI differences such as JSON-line listings versus array inspections, full network IDs versus name-only mutations, container port/mount layouts, structured labels, repeated typed label keys (last value wins), and single-container `start` commands. These are not reasons to disable the corresponding conformance cases. + +To run real-runtime tests, first build prerequisites, then enable the opt-in in the same PowerShell invocation: + +```powershell +make test-prereqs +$env:DCP_TEST_ENABLE_TRUE_CONTAINER_ORCHESTRATOR = 'true' +$env:TEST_CONTEXT_TIMEOUT = '180' +go test -count 1 -parallel 32 -timeout 3m .\test\containers +make test +``` + +The shared runner exercises every healthy installed runtime and uses ownership labels, unique names, and cleanup journals. Confirm that WSLC subtests actually ran rather than relying only on the test process's exit code. Do not use runtime-wide prune or terminate a WSLC session to clean test resources. + +The temporary native-event gates apply to `TestWatchContainersMethod/wslc`, `TestWatchNetworksMethod/wslc`, and `TestTunnelProxyWithRealOrchestrator/wslc`. Remove them when the native CLI event stream and DCP watchers are implemented. Non-event conformance, image-layer builds, and direct terminal coverage remain enabled; any further gap needs specific evidence rather than a blanket WSLC skip. + ### Taking performance traces See [performance investigations page](doc/performance-investigations.md). diff --git a/controllers/network_controller.go b/controllers/network_controller.go index a5ccd406..f9d6517c 100644 --- a/controllers/network_controller.go +++ b/controllers/network_controller.go @@ -120,6 +120,7 @@ type NetworkReconciler struct { networkEvtSub *pubsub.Subscription[containers.EventMessage] // Channel to receive network change events networkEvtCh *concurrency.UnboundedChan[containers.EventMessage] + networkEvtChCancel context.CancelFunc networkEvtWorkerStop chan struct{} // Count of existing Container resources @@ -849,8 +850,10 @@ func (r *NetworkReconciler) ensureNetworkWatch(network *apiv1.ContainerNetwork, return // We are already watching container events } + eventCtx, eventCancel := context.WithCancel(r.LifetimeCtx) + r.networkEvtChCancel = eventCancel r.networkEvtCh = concurrency.NewUnboundedChanBuffered[containers.EventMessage]( - r.LifetimeCtx, + eventCtx, containerEventChanBuffer, containerEventChanBuffer, ) @@ -860,14 +863,15 @@ func (r *NetworkReconciler) ensureNetworkWatch(network *apiv1.ContainerNetwork, log.V(1).Info("Subscribing to container events...") sub, err := r.orchestrator.WatchNetworks(r.networkEvtCh.In) + r.networkEvtSub = sub + if err == nil && sub == nil { + err = fmt.Errorf("container runtime returned no network event subscription") + } if err != nil { log.Error(err, "Could not subscribe to network events") - close(r.networkEvtWorkerStop) - r.networkEvtWorkerStop = nil + r.cancelNetworkWatch() return } - - r.networkEvtSub = sub } func (r *NetworkReconciler) releaseNetworkWatch(network *apiv1.ContainerNetwork, log logr.Logger) { @@ -946,6 +950,11 @@ func (r *NetworkReconciler) cancelNetworkWatch() { r.networkEvtSub.Cancel() r.networkEvtSub = nil } + if r.networkEvtChCancel != nil { + r.networkEvtChCancel() + r.networkEvtChCancel = nil + } + r.networkEvtCh = nil } func (r *NetworkReconciler) onShutdown() { diff --git a/controllers/network_controller_watch_test.go b/controllers/network_controller_watch_test.go new file mode 100644 index 00000000..37b773fd --- /dev/null +++ b/controllers/network_controller_watch_test.go @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "testing" + + "github.com/go-logr/logr/testr" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiv1 "github.com/microsoft/dcp/api/v1" + "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/internal/pubsub" + "github.com/microsoft/dcp/pkg/testutil" +) + +type networkWatchTestOrchestrator struct { + containers.ContainerOrchestrator + watch func(chan<- containers.EventMessage) (*pubsub.Subscription[containers.EventMessage], error) +} + +func (orchestrator networkWatchTestOrchestrator) WatchNetworks(sink chan<- containers.EventMessage) (*pubsub.Subscription[containers.EventMessage], error) { + return orchestrator.watch(sink) +} + +func TestNetworkWatchFailureReleasesChannels(t *testing.T) { + t.Parallel() + + watchErr := errors.New("native event watching is unavailable") + for _, testCase := range []struct { + name string + returnSubscription bool + err error + }{ + {name: "unsupported", err: watchErr}, + {name: "partial subscription", returnSubscription: true, err: watchErr}, + {name: "missing subscription"}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + lifetimeCtx, lifetimeCancel := testutil.GetTestContext(t, 0) + defer lifetimeCancel() + subscriptions := pubsub.NewSubscriptionSet(func(ctx context.Context, _ *pubsub.SubscriptionSet[containers.EventMessage]) { + <-ctx.Done() + }, lifetimeCtx) + var reconciler *NetworkReconciler + var output <-chan containers.EventMessage + var subscription *pubsub.Subscription[containers.EventMessage] + orchestrator := networkWatchTestOrchestrator{ + watch: func(sink chan<- containers.EventMessage) (*pubsub.Subscription[containers.EventMessage], error) { + output = reconciler.networkEvtCh.Out + subscription = nil + if testCase.returnSubscription { + subscription = subscriptions.Subscribe(sink) + } + return subscription, testCase.err + }, + } + log := testr.New(t) + reconciler = NewNetworkReconciler(lifetimeCtx, nil, nil, log, orchestrator, nil) + network := &apiv1.ContainerNetwork{ObjectMeta: metav1.ObjectMeta{UID: "network"}} + + for attempt := 0; attempt < 3; attempt++ { + reconciler.ensureNetworkWatch(network, log) + require.Nil(t, reconciler.networkEvtCh) + require.Nil(t, reconciler.networkEvtChCancel) + require.Nil(t, reconciler.networkEvtSub) + require.Nil(t, reconciler.networkEvtWorkerStop) + if subscription != nil { + require.True(t, subscription.Cancelled()) + } + requireNetworkEventChannelClosed(t, lifetimeCtx, output) + require.NoError(t, lifetimeCtx.Err()) + } + }) + } +} + +func TestNetworkWatchReleaseClosesChannelsBeforeShutdown(t *testing.T) { + t.Parallel() + + lifetimeCtx, lifetimeCancel := testutil.GetTestContext(t, 0) + defer lifetimeCancel() + subscriptions := pubsub.NewSubscriptionSet(func(ctx context.Context, _ *pubsub.SubscriptionSet[containers.EventMessage]) { + <-ctx.Done() + }, lifetimeCtx) + orchestrator := networkWatchTestOrchestrator{watch: func(sink chan<- containers.EventMessage) (*pubsub.Subscription[containers.EventMessage], error) { + return subscriptions.Subscribe(sink), nil + }} + log := testr.New(t) + reconciler := NewNetworkReconciler(lifetimeCtx, nil, nil, log, orchestrator, nil) + first := &apiv1.ContainerNetwork{ObjectMeta: metav1.ObjectMeta{UID: "first"}} + second := &apiv1.ContainerNetwork{ObjectMeta: metav1.ObjectMeta{UID: "second"}} + + reconciler.ensureNetworkWatch(first, log) + subscription := reconciler.networkEvtSub + output := reconciler.networkEvtCh.Out + reconciler.ensureNetworkWatch(second, log) + require.Same(t, subscription, reconciler.networkEvtSub) + + reconciler.releaseNetworkWatch(first, log) + require.False(t, subscription.Cancelled()) + reconciler.releaseNetworkWatch(second, log) + require.True(t, subscription.Cancelled()) + require.Nil(t, reconciler.networkEvtCh) + require.Nil(t, reconciler.networkEvtChCancel) + requireNetworkEventChannelClosed(t, lifetimeCtx, output) + require.NoError(t, lifetimeCtx.Err()) +} + +func requireNetworkEventChannelClosed(t *testing.T, ctx context.Context, output <-chan containers.EventMessage) { + t.Helper() + require.NotNil(t, output) + select { + case _, open := <-output: + require.False(t, open, "network event channel should close without controller shutdown") + case <-ctx.Done(): + t.Fatalf("network event channel was not released: %v", ctx.Err()) + } +} diff --git a/internal/containers/container_orchestrator.go b/internal/containers/container_orchestrator.go index 770bb71e..c6679cfb 100644 --- a/internal/containers/container_orchestrator.go +++ b/internal/containers/container_orchestrator.go @@ -655,7 +655,7 @@ type ContainerOrchestrator interface { // Get the name of the runtime Name() string - // Get the container machine host name for the runtime + // Get the default container-to-host address, or an empty string if the runtime does not provide one. ContainerHost() string // Start running background checks for the runtime status diff --git a/internal/containers/create_files.go b/internal/containers/create_files.go new file mode 100644 index 00000000..bedfaa43 --- /dev/null +++ b/internal/containers/create_files.go @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package containers + +import ( + "bytes" + "context" + + "github.com/go-logr/logr" + + usvc_io "github.com/microsoft/dcp/pkg/io" +) + +// CreateFilesArchive creates the tar archive consumed by container copy commands. +// A nil buffer and nil error indicate that no entries were written. +func CreateFilesArchive(ctx context.Context, log logr.Logger, options CreateFilesOptions) (*bytes.Buffer, error) { + if cancellationErr := ctx.Err(); cancellationErr != nil { + return nil, cancellationErr + } + + tarWriter := usvc_io.NewTarWriter() + certificateHashes := []string{} + + for _, item := range options.Entries { + if cancellationErr := ctx.Err(); cancellationErr != nil { + return nil, cancellationErr + } + + switch item.Type { + case FileSystemEntryTypeDir: + if addDirectoryErr := AddDirectoryToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, log); addDirectoryErr != nil { + return nil, addDirectoryErr + } + case 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 FileSystemEntryTypeOpenSSL: + hash, addCertificateErr := AddCertificateToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, certificateHashes, log) + if addCertificateErr != nil { + if item.ContinueOnError { + log.Error(addCertificateErr, "Failed to add a certificate to the tar file, but continueOnError is set", "Certificate", item) + } else { + return nil, addCertificateErr + } + } + + 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 cancellationErr := ctx.Err(); cancellationErr != nil { + return nil, cancellationErr + } + if tarWriter.Empty() { + return nil, nil + } + + buffer, bufferErr := tarWriter.Buffer() + if bufferErr != nil { + return nil, bufferErr + } + return buffer, nil +} diff --git a/internal/containers/create_files_test.go b/internal/containers/create_files_test.go new file mode 100644 index 00000000..cf51b786 --- /dev/null +++ b/internal/containers/create_files_test.go @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "io" + "math/big" + "strings" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + usvc_io "github.com/microsoft/dcp/pkg/io" +) + +func TestCreateFilesArchiveMatchesExistingAssembly(t *testing.T) { + t.Parallel() + + certificate := createArchiveTestCertificate(t) + fileOwner := int32(1001) + fileGroup := int32(1002) + modTime := time.Date(2026, time.September, 13, 20, 15, 0, 0, time.UTC) + options := CreateFilesOptions{ + ModTime: modTime, + Destination: "/workspace", + DefaultOwner: 101, + DefaultGroup: 202, + Umask: 0027, + Entries: []FileSystemEntry{ + { + Name: "inline.txt", + Contents: "inline contents", + Owner: &fileOwner, + Group: &fileGroup, + Mode: 0640, + }, + { + Name: "raw.bin", + RawContents: base64.StdEncoding.EncodeToString([]byte{0, 1, 2, 3}), + }, + { + Type: FileSystemEntryTypeSymlink, + Name: "inline-link", + Target: "./inline.txt", + }, + { + Type: FileSystemEntryTypeDir, + Name: "nested", + Mode: 0750, + Entries: []FileSystemEntry{ + {Name: "nested.txt", Contents: "nested contents"}, + {Type: FileSystemEntryTypeSymlink, Name: "nested-link", Target: "./nested.txt"}, + }, + }, + { + Type: FileSystemEntryTypeOpenSSL, + Name: "certificate-a.pem", + Contents: certificate, + }, + { + Type: FileSystemEntryTypeOpenSSL, + Name: "certificate-b.pem", + Contents: certificate, + }, + }, + } + + expected := assembleCreateFilesArchiveDirectly(t, options) + actual, archiveErr := CreateFilesArchive(context.Background(), logr.Discard(), options) + + require.NoError(t, archiveErr) + require.NotNil(t, actual) + assert.Equal(t, expected.Bytes(), actual.Bytes()) + + var certificateLinks []string + reader := tar.NewReader(bytes.NewReader(actual.Bytes())) + for { + header, nextErr := reader.Next() + if nextErr == io.EOF { + break + } + require.NoError(t, nextErr) + if header.Typeflag == tar.TypeSymlink && strings.HasPrefix(header.Linkname, "./certificate-") { + certificateLinks = append(certificateLinks, header.Name) + } + } + require.Len(t, certificateLinks, 2) + assert.True(t, strings.HasSuffix(certificateLinks[0], ".0")) + assert.True(t, strings.HasSuffix(certificateLinks[1], ".1")) +} + +func TestCreateFilesArchiveReturnsNilWhenAllIgnorableEntriesFail(t *testing.T) { + t.Parallel() + + buffer, archiveErr := CreateFilesArchive(context.Background(), logr.Discard(), CreateFilesOptions{ + Destination: "/workspace", + Entries: []FileSystemEntry{ + {Name: "bad-raw", RawContents: "%%%", ContinueOnError: true}, + {Type: FileSystemEntryTypeOpenSSL, Name: "bad-cert.pem", Contents: "not a certificate", ContinueOnError: true}, + }, + }) + + require.NoError(t, archiveErr) + assert.Nil(t, buffer) +} + +func TestCreateFilesArchiveReturnsEntryError(t *testing.T) { + t.Parallel() + + buffer, archiveErr := CreateFilesArchive(context.Background(), logr.Discard(), CreateFilesOptions{ + Destination: "/workspace", + Entries: []FileSystemEntry{ + {Name: "bad-raw", RawContents: "%%%"}, + }, + }) + + require.Error(t, archiveErr) + assert.Contains(t, archiveErr.Error(), "could not decode rawContents") + assert.Nil(t, buffer) +} + +func TestCreateFilesArchiveHonorsCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + buffer, archiveErr := CreateFilesArchive(ctx, logr.Discard(), CreateFilesOptions{ + Destination: "/workspace", + Entries: []FileSystemEntry{ + {Name: "file.txt", Contents: "contents"}, + }, + }) + + require.ErrorIs(t, archiveErr, context.Canceled) + assert.Nil(t, buffer) +} + +func assembleCreateFilesArchiveDirectly(t *testing.T, options CreateFilesOptions) *bytes.Buffer { + t.Helper() + + tarWriter := usvc_io.NewTarWriter() + certificateHashes := []string{} + for _, item := range options.Entries { + switch item.Type { + case FileSystemEntryTypeDir: + require.NoError(t, AddDirectoryToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, logr.Discard())) + case FileSystemEntryTypeSymlink: + require.NoError(t, AddSymlinkToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, logr.Discard())) + case FileSystemEntryTypeOpenSSL: + hash, addCertificateErr := AddCertificateToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, certificateHashes, logr.Discard()) + require.NoError(t, addCertificateErr) + certificateHashes = append(certificateHashes, hash) + default: + require.NoError(t, AddFileToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, logr.Discard())) + } + } + + buffer, bufferErr := tarWriter.Buffer() + require.NoError(t, bufferErr) + return buffer +} + +func createArchiveTestCertificate(t *testing.T) string { + t.Helper() + + publicKey, privateKey, keyErr := ed25519.GenerateKey(rand.Reader) + require.NoError(t, keyErr) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "CreateFilesArchive test"}, + NotBefore: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + derBytes, createErr := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey) + require.NoError(t, createErr) + + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})) +} diff --git a/internal/containers/flags/container_runtime.go b/internal/containers/flags/container_runtime.go index 13a5d5e7..2711a1bd 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{string(DockerRuntime), string(PodmanRuntime), string(WslcRuntime)} runtime = UnknownRuntime ) diff --git a/internal/containers/flags/container_runtime_test.go b/internal/containers/flags/container_runtime_test.go new file mode 100644 index 00000000..0597befd --- /dev/null +++ b/internal/containers/flags/container_runtime_test.go @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package flags + +import ( + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" +) + +func TestRuntimeFlagValues(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + input string + want RuntimeFlagValue + }{ + {input: "", want: UnknownRuntime}, + {input: "docker", want: DockerRuntime}, + {input: "PODMAN", want: PodmanRuntime}, + {input: "wslc", want: WslcRuntime}, + {input: "WsLc", want: WslcRuntime}, + } { + t.Run(testCase.input, func(t *testing.T) { + t.Parallel() + + var value RuntimeFlagValue + require.NoError(t, value.Set(testCase.input)) + require.Equal(t, testCase.want, value) + }) + } +} + +func TestRuntimeFlagRejectsUnknownWithoutChangingValue(t *testing.T) { + t.Parallel() + + value := WslcRuntime + require.ErrorContains(t, value.Set("unknown"), "docker, podman, wslc") + require.Equal(t, WslcRuntime, value) +} + +func TestRuntimeFlagHelpIncludesWSLC(t *testing.T) { + flagSet := pflag.NewFlagSet(t.Name(), pflag.ContinueOnError) + EnsureRuntimeFlag(flagSet) + + runtimeFlag := flagSet.Lookup(RuntimeFlagName) + require.NotNil(t, runtimeFlag) + require.Contains(t, runtimeFlag.Usage, "docker, podman, wslc") +} diff --git a/internal/containers/image_id_test.go b/internal/containers/image_id_test.go new file mode 100644 index 00000000..60003e92 --- /dev/null +++ b/internal/containers/image_id_test.go @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package containers + +import ( + "crypto/sha256" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + usvc_io "github.com/microsoft/dcp/pkg/io" + "github.com/microsoft/dcp/pkg/osutil" +) + +func TestReadImageIDFile(t *testing.T) { + t.Parallel() + + validImageID := "SHA256:" + strings.Repeat("A", sha256.Size*2) + testCases := []struct { + name string + contents string + expected string + errorContains string + }{ + { + name: "valid", + contents: " \r\n" + validImageID + "\r\n ", + expected: validImageID, + }, + { + name: "oversized", + contents: strings.Repeat("a", maxImageIDFileSize+1), + errorContains: "exceeds 1024 bytes", + }, + { + name: "short", + contents: "sha256:" + strings.Repeat("a", sha256.Size*2-1), + errorContains: "expected sha256: followed by 64 hexadecimal characters", + }, + { + name: "nonhex", + contents: "sha256:" + strings.Repeat("a", sha256.Size*2-1) + "g", + errorContains: "decoding SHA256 value", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "image.iid") + require.NoError(t, usvc_io.WriteFile(path, []byte(testCase.contents), osutil.PermissionOnlyOwnerReadWrite)) + + imageID, readErr := ReadImageIDFile(path) + + if testCase.errorContains == "" { + require.NoError(t, readErr) + assert.Equal(t, testCase.expected, imageID) + } else { + require.Error(t, readErr) + assert.Contains(t, readErr.Error(), testCase.errorContains) + assert.Empty(t, imageID) + } + }) + } +} + +func TestReadImageIDFileRejectsNonRegularFile(t *testing.T) { + t.Parallel() + + imageID, readErr := ReadImageIDFile(t.TempDir()) + + require.Error(t, readErr) + assert.Contains(t, readErr.Error(), "is not a regular file") + assert.Empty(t, imageID) +} diff --git a/internal/containers/image_layers.go b/internal/containers/image_layers.go index a130c9e3..4a098876 100644 --- a/internal/containers/image_layers.go +++ b/internal/containers/image_layers.go @@ -10,18 +10,24 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "errors" "fmt" "io" "os" + "path/filepath" "strings" "time" "github.com/go-logr/logr" usvc_io "github.com/microsoft/dcp/pkg/io" + "github.com/microsoft/dcp/pkg/osutil" ) -const defaultApplyImageLayersTimeout = 10 * time.Minute +const ( + defaultApplyImageLayersTimeout = 10 * time.Minute + maxImageIDFileSize = 1024 +) // ImageLayer represents a tar file to be applied as an additional image layer when running the // container. The layer is provided either as a path to a tar file (with a SHA256 hash for @@ -53,8 +59,9 @@ func ApplyImageLayersImpl( options ApplyImageLayersOptions, runner CLICommandRunner, ) (string, error) { - if len(options.Layers) == 0 { - return "", fmt.Errorf("at least one image layer must be specified") + dockerfile, prepareErr := prepareImageLayerDockerfile(options) + if prepareErr != nil { + return "", prepareErr } timeout := options.Timeout @@ -62,16 +69,8 @@ func ApplyImageLayersImpl( timeout = defaultApplyImageLayersTimeout } - // 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] - } - - // First pass: verify source-file layers and build the Dockerfile content. - // Source layers are hash-verified here (streaming, no full buffer) so that - // the second pass can stream them directly into the tar. - dockerfile := fmt.Sprintf("FROM %s\n", baseImage) + // Source layers are hash-verified here so the second pass can stream them + // directly into the tar without buffering their full contents. for i := range options.Layers { layer := &options.Layers[i] @@ -81,8 +80,6 @@ func ApplyImageLayersImpl( } log.V(1).Info("Layer source SHA256 verified", "Source", layer.Source, "Digest", layer.Digest) } - - dockerfile += fmt.Sprintf("ADD layer%d.tar /\n", i) } // Second pass: stream the tar archive directly to docker build via io.Pipe. @@ -107,7 +104,7 @@ func ApplyImageLayersImpl( for i := range options.Layers { layer := &options.Layers[i] - layerFileName := fmt.Sprintf("layer%d.tar", i) + layerFileName := imageLayerFileName(i) if layer.Source != "" { if streamErr := streamLayerFromSource(tw, layer, layerFileName, now); streamErr != nil { @@ -187,10 +184,326 @@ func ApplyImageLayersImpl( return imageRef, nil } +// ApplyImageLayersFromDirectory builds a derived image from a disk-backed build context. +func ApplyImageLayersFromDirectory( + ctx context.Context, + log logr.Logger, + options ApplyImageLayersOptions, + builder BuildImage, +) (string, error) { + return applyImageLayersFromDirectory(ctx, log, options, builder, usvc_io.DcpTempDir()) +} + +func applyImageLayersFromDirectory( + ctx context.Context, + log logr.Logger, + options ApplyImageLayersOptions, + builder BuildImage, + tempDirectory string, +) (imageRef string, returnErr error) { + if cancellationErr := ctx.Err(); cancellationErr != nil { + return "", cancellationErr + } + + dockerfile, prepareErr := prepareImageLayerDockerfile(options) + if prepareErr != nil { + return "", prepareErr + } + + workspace, workspaceErr := createImageLayerWorkspace(tempDirectory) + if workspaceErr != nil { + return "", workspaceErr + } + defer func() { + if cleanupErr := os.RemoveAll(workspace); cleanupErr != nil { + imageRef = "" + returnErr = errors.Join(returnErr, fmt.Errorf("removing image layer build workspace %q: %w", workspace, cleanupErr)) + } + }() + + contextDirectory := filepath.Join(workspace, "context") + if contextErr := usvc_io.EnsureRestrictedDirectory(contextDirectory, osutil.PermissionOnlyOwnerReadWriteTraverse); contextErr != nil { + return "", fmt.Errorf("creating image layer build context: %w", contextErr) + } + + dockerfilePath := filepath.Join(contextDirectory, "Dockerfile") + if dockerfileErr := writeImageLayerBuildFile(ctx, dockerfilePath, strings.NewReader(dockerfile), nil); dockerfileErr != nil { + return "", fmt.Errorf("writing Dockerfile to image layer build context: %w", dockerfileErr) + } + + for layerIndex := range options.Layers { + if cancellationErr := ctx.Err(); cancellationErr != nil { + return "", cancellationErr + } + + layer := &options.Layers[layerIndex] + layerPath := filepath.Join(contextDirectory, imageLayerFileName(layerIndex)) + if layer.Source != "" { + if stageErr := stageImageLayerSource(ctx, layerPath, layer); stageErr != nil { + return "", fmt.Errorf("staging image layer %d: %w", layerIndex, stageErr) + } + log.V(1).Info("Layer source SHA256 verified", "Source", layer.Source, "Digest", layer.Digest) + } else { + decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(layer.RawContents)) + if stageErr := writeImageLayerBuildFile(ctx, layerPath, decoder, nil); stageErr != nil { + return "", fmt.Errorf("staging base64 rawContents for layer %d (%q): %w", layerIndex, layer.Digest, stageErr) + } + } + } + + if cancellationErr := ctx.Err(); cancellationErr != nil { + return "", cancellationErr + } + + timeout := options.Timeout + if timeout == 0 { + timeout = defaultApplyImageLayersTimeout + } + + tags := []string(nil) + iidFilePath := "" + if options.Tag != "" { + tags = []string{options.Tag} + } else { + iidFilePath = filepath.Join(workspace, "image.iid") + iidFile, createIidErr := usvc_io.CreateNewFile(iidFilePath, osutil.PermissionOnlyOwnerReadWrite) + if createIidErr != nil { + return "", fmt.Errorf("creating image ID file: %w", createIidErr) + } + if closeIidErr := iidFile.Close(); closeIidErr != nil { + return "", fmt.Errorf("closing image ID file before build: %w", closeIidErr) + } + } + + if cancellationErr := ctx.Err(); cancellationErr != nil { + return "", cancellationErr + } + + buildErr := builder.BuildImage(ctx, BuildImageOptions{ + IidFile: iidFilePath, + ContainerBuildContext: &ContainerBuildContext{ + Context: contextDirectory, + Dockerfile: dockerfilePath, + Tags: tags, + Labels: options.Labels, + }, + TimeoutOption: TimeoutOption{Timeout: timeout}, + }) + if buildErr != nil { + return "", fmt.Errorf("building derived image with image layers: %w", buildErr) + } + if cancellationErr := ctx.Err(); cancellationErr != nil { + return "", fmt.Errorf("building derived image with image layers: %w", cancellationErr) + } + + imageRef = options.Tag + if imageRef == "" { + builtImageID, readIidErr := ReadImageIDFile(iidFilePath) + if readIidErr != nil { + return "", fmt.Errorf("reading derived image ID: %w", readIidErr) + } + imageRef = builtImageID + } + + log.V(1).Info("Built derived image with image layers", "ImageRef", imageRef, "LayerCount", len(options.Layers)) + return imageRef, nil +} + +func prepareImageLayerDockerfile(options ApplyImageLayersOptions) (string, error) { + if len(options.Layers) == 0 { + return "", fmt.Errorf("at least one image layer must be specified") + } + + baseImage := options.BaseImage.Id + if len(options.BaseImage.Tags) > 0 { + baseImage = options.BaseImage.Tags[0] + } + + var dockerfile strings.Builder + dockerfile.WriteString("FROM ") + dockerfile.WriteString(baseImage) + dockerfile.WriteByte('\n') + for layerIndex := range options.Layers { + dockerfile.WriteString("ADD ") + dockerfile.WriteString(imageLayerFileName(layerIndex)) + dockerfile.WriteString(" /\n") + } + return dockerfile.String(), nil +} + +func imageLayerFileName(layerIndex int) string { + return fmt.Sprintf("layer%d.tar", layerIndex) +} + +func createImageLayerWorkspace(tempDirectory string) (string, error) { + workspace, createErr := os.MkdirTemp(tempDirectory, "dcp-image-layers-") + if createErr != nil { + return "", fmt.Errorf("creating image layer build workspace: %w", createErr) + } + + if restrictErr := usvc_io.EnsureRestrictedDirectory(workspace, osutil.PermissionOnlyOwnerReadWriteTraverse); restrictErr != nil { + cleanupErr := os.RemoveAll(workspace) + return "", errors.Join( + fmt.Errorf("restricting image layer build workspace %q: %w", workspace, restrictErr), + wrapImageLayerCleanupError(workspace, cleanupErr), + ) + } + return workspace, nil +} + +func wrapImageLayerCleanupError(workspace string, cleanupErr error) error { + if cleanupErr == nil { + return nil + } + return fmt.Errorf("removing image layer build workspace %q: %w", workspace, cleanupErr) +} + +func writeImageLayerBuildFile(ctx context.Context, name string, source io.Reader, observer io.Writer) error { + file, createErr := usvc_io.CreateNewFile(name, osutil.PermissionOnlyOwnerReadWrite) + if createErr != nil { + return fmt.Errorf("creating build context file %q: %w", name, createErr) + } + + destination := io.Writer(file) + if observer != nil { + destination = io.MultiWriter(file, observer) + } + + _, copyErr := copyImageLayerContents(ctx, destination, source) + closeErr := file.Close() + if copyErr != nil { + return errors.Join( + fmt.Errorf("writing build context file %q: %w", name, copyErr), + wrapImageLayerFileCloseError(name, closeErr), + ) + } + if closeErr != nil { + return fmt.Errorf("closing build context file %q: %w", name, closeErr) + } + return nil +} + +func wrapImageLayerFileCloseError(name string, closeErr error) error { + if closeErr == nil { + return nil + } + return fmt.Errorf("closing build context file %q: %w", name, closeErr) +} + +func copyImageLayerContents(ctx context.Context, destination io.Writer, source io.Reader) (int64, error) { + buffer := make([]byte, 128*1024) + var total int64 + + for { + if cancellationErr := ctx.Err(); cancellationErr != nil { + return total, cancellationErr + } + + readCount, readErr := source.Read(buffer) + if readCount > 0 { + writeCount, writeErr := destination.Write(buffer[:readCount]) + total += int64(writeCount) + if writeErr != nil { + return total, writeErr + } + if writeCount != readCount { + return total, io.ErrShortWrite + } + } + + if errors.Is(readErr, io.EOF) { + return total, nil + } + if readErr != nil { + return total, readErr + } + } +} + +func stageImageLayerSource(ctx context.Context, destination string, layer *ImageLayer) error { + sourceFile, openErr := usvc_io.OpenFileReadOnly(layer.Source) + if openErr != nil { + return fmt.Errorf("opening layer source file %q: %w", layer.Source, openErr) + } + + hasher := sha256.New() + stageErr := writeImageLayerBuildFile(ctx, destination, sourceFile, hasher) + closeErr := sourceFile.Close() + if stageErr != nil { + return errors.Join(stageErr, wrapImageLayerSourceCloseError(layer.Source, closeErr)) + } + if closeErr != nil { + return fmt.Errorf("closing layer source file %q: %w", layer.Source, closeErr) + } + + actualHashHex := hex.EncodeToString(hasher.Sum(nil)) + return verifyLayerHash(layer, actualHashHex) +} + +func wrapImageLayerSourceCloseError(source string, closeErr error) error { + if closeErr == nil { + return nil + } + return fmt.Errorf("closing layer source file %q: %w", source, closeErr) +} + +// ReadImageIDFile reads and validates a bounded SHA256 image ID from a regular file. +func ReadImageIDFile(name string) (string, error) { + info, statErr := os.Lstat(name) + if statErr != nil { + return "", fmt.Errorf("inspecting image ID file %q: %w", name, statErr) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("image ID file %q is not a regular file", name) + } + + file, openErr := usvc_io.EnsureFile(name, osutil.PermissionOnlyOwnerReadWrite) + if openErr != nil { + return "", fmt.Errorf("opening image ID file %q: %w", name, openErr) + } + + contents, readErr := io.ReadAll(io.LimitReader(file, maxImageIDFileSize+1)) + closeErr := file.Close() + if readErr != nil { + return "", errors.Join( + fmt.Errorf("reading image ID file %q: %w", name, readErr), + wrapImageLayerFileCloseError(name, closeErr), + ) + } + if closeErr != nil { + return "", fmt.Errorf("closing image ID file %q: %w", name, closeErr) + } + if len(contents) > maxImageIDFileSize { + return "", fmt.Errorf("image ID file %q exceeds %d bytes", name, maxImageIDFileSize) + } + + imageID := strings.TrimSpace(string(contents)) + if imageID == "" { + return "", fmt.Errorf("image ID file is empty") + } + if validateErr := validateBuiltImageID(imageID); validateErr != nil { + return "", fmt.Errorf("invalid image ID %q: %w", imageID, validateErr) + } + return imageID, nil +} + +func validateBuiltImageID(imageID string) error { + const sha256Prefix = "sha256:" + const sha256HexLength = sha256.Size * 2 + + if len(imageID) != len(sha256Prefix)+sha256HexLength || !strings.EqualFold(imageID[:len(sha256Prefix)], sha256Prefix) { + return fmt.Errorf("expected %s followed by %d hexadecimal characters", sha256Prefix, sha256HexLength) + } + if _, decodeErr := hex.DecodeString(imageID[len(sha256Prefix):]); decodeErr != nil { + return fmt.Errorf("decoding SHA256 value: %w", decodeErr) + } + return nil +} + // verifyLayerSourceHash streams the source file through a SHA256 hasher // and verifies the hash matches, without buffering the full file in memory. func verifyLayerSourceHash(layer *ImageLayer) error { - f, openErr := os.Open(layer.Source) + f, openErr := usvc_io.OpenFileReadOnly(layer.Source) if openErr != nil { return fmt.Errorf("opening layer source file %q: %w", layer.Source, openErr) } @@ -202,6 +515,10 @@ func verifyLayerSourceHash(layer *ImageLayer) error { } actualHashHex := hex.EncodeToString(hasher.Sum(nil)) + return verifyLayerHash(layer, actualHashHex) +} + +func verifyLayerHash(layer *ImageLayer, actualHashHex string) error { expectedHash := strings.TrimSpace(layer.SHA256) if strings.HasPrefix(strings.ToLower(expectedHash), "sha256:") { expectedHash = expectedHash[7:] @@ -216,7 +533,7 @@ func verifyLayerSourceHash(layer *ImageLayer) error { // streamLayerFromSource streams a source-file layer directly into the tar writer // without buffering the full file contents in memory. func streamLayerFromSource(tw *usvc_io.TarWriter, layer *ImageLayer, tarName string, modTime time.Time) error { - f, openErr := os.Open(layer.Source) + f, openErr := usvc_io.OpenFileReadOnly(layer.Source) if openErr != nil { return fmt.Errorf("opening layer source file %q: %w", layer.Source, openErr) } diff --git a/internal/containers/image_layers_directory_test.go b/internal/containers/image_layers_directory_test.go new file mode 100644 index 00000000..0bcb054e --- /dev/null +++ b/internal/containers/image_layers_directory_test.go @@ -0,0 +1,327 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package containers + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + usvc_io "github.com/microsoft/dcp/pkg/io" + "github.com/microsoft/dcp/pkg/osutil" +) + +type buildImageFunc func(context.Context, BuildImageOptions) error + +func (f buildImageFunc) BuildImage(ctx context.Context, options BuildImageOptions) error { + return f(ctx, options) +} + +func TestApplyImageLayersFromDirectoryStagesRawAndSourceLayers(t *testing.T) { + t.Parallel() + + rawLayer := []byte("raw layer contents") + sourceLayer := []byte("source layer contents") + sourceHash := sha256.Sum256(sourceLayer) + sourceDirectory := t.TempDir() + sourcePath := filepath.Join(sourceDirectory, "source-layer.tar") + require.NoError(t, usvc_io.WriteFile(sourcePath, sourceLayer, osutil.PermissionOnlyOwnerReadWrite)) + + labels := []Label{ + {Key: "first.label", Value: "first value"}, + {Key: "second.label", Value: "second value"}, + } + options := ApplyImageLayersOptions{ + BaseImage: InspectedImage{ + Id: "sha256:base-id", + Tags: []string{"example.test/base:tag"}, + }, + Layers: []ImageLayer{ + {Digest: "raw", RawContents: base64.StdEncoding.EncodeToString(rawLayer)}, + { + Digest: "source", + Source: sourcePath, + SHA256: "SHA256:" + strings.ToUpper(hex.EncodeToString(sourceHash[:])), + }, + }, + Labels: labels, + Tag: "example.test/derived:tag", + TimeoutOption: TimeoutOption{ + Timeout: 37 * time.Second, + }, + } + + var workspace string + builder := buildImageFunc(func(ctx context.Context, buildOptions BuildImageOptions) error { + require.NoError(t, ctx.Err()) + workspace = filepath.Dir(buildOptions.Context) + require.NoError(t, usvc_io.ValidateRestrictedDirectory(workspace, osutil.PermissionOnlyOwnerReadWriteTraverse)) + require.NoError(t, usvc_io.ValidateRestrictedDirectory(buildOptions.Context, osutil.PermissionOnlyOwnerReadWriteTraverse)) + + assert.Equal(t, filepath.Join(buildOptions.Context, "Dockerfile"), buildOptions.Dockerfile) + assert.Equal(t, []string{options.Tag}, buildOptions.Tags) + assert.Equal(t, labels, buildOptions.Labels) + assert.Equal(t, options.Timeout, buildOptions.Timeout) + assert.Empty(t, buildOptions.IidFile) + + dockerfile := readImageLayerTestFile(t, buildOptions.Dockerfile) + assert.Equal(t, "FROM example.test/base:tag\nADD layer0.tar /\nADD layer1.tar /\n", string(dockerfile)) + assert.Equal(t, rawLayer, readImageLayerTestFile(t, filepath.Join(buildOptions.Context, "layer0.tar"))) + assert.Equal(t, sourceLayer, readImageLayerTestFile(t, filepath.Join(buildOptions.Context, "layer1.tar"))) + + entries, readDirectoryErr := os.ReadDir(buildOptions.Context) + require.NoError(t, readDirectoryErr) + require.Len(t, entries, 3) + assert.Equal(t, "Dockerfile", entries[0].Name()) + assert.Equal(t, "layer0.tar", entries[1].Name()) + assert.Equal(t, "layer1.tar", entries[2].Name()) + return nil + }) + + imageRef, applyErr := ApplyImageLayersFromDirectory(context.Background(), logr.Discard(), options, builder) + + require.NoError(t, applyErr) + assert.Equal(t, options.Tag, imageRef) + require.NotEmpty(t, workspace) + assertPathRemoved(t, workspace) +} + +func TestApplyImageLayersFromDirectoryRejectsStagedSourceHashMismatch(t *testing.T) { + t.Parallel() + + sourceDirectory := t.TempDir() + sourcePath := filepath.Join(sourceDirectory, "source-layer.tar") + require.NoError(t, usvc_io.WriteFile(sourcePath, []byte("source layer contents"), osutil.PermissionOnlyOwnerReadWrite)) + + builderCalled := false + tempDirectory := t.TempDir() + imageRef, applyErr := applyImageLayersFromDirectory( + context.Background(), + logr.Discard(), + ApplyImageLayersOptions{ + BaseImage: InspectedImage{Id: "sha256:base"}, + Layers: []ImageLayer{{ + Digest: "source", + Source: sourcePath, + SHA256: strings.Repeat("0", sha256.Size*2), + }}, + Tag: "derived:tag", + }, + buildImageFunc(func(context.Context, BuildImageOptions) error { + builderCalled = true + return nil + }), + tempDirectory, + ) + + require.Error(t, applyErr) + assert.Contains(t, applyErr.Error(), "SHA256 mismatch") + assert.Empty(t, imageRef) + assert.False(t, builderCalled) + assertDirectoryEmpty(t, tempDirectory) +} + +func TestApplyImageLayersFromDirectoryReturnsValidatedImageID(t *testing.T) { + t.Parallel() + + expectedImageID := "SHA256:" + strings.Repeat("A", sha256.Size*2) + tempDirectory := t.TempDir() + var workspace string + builder := buildImageFunc(func(_ context.Context, buildOptions BuildImageOptions) error { + workspace = filepath.Dir(buildOptions.Context) + require.Empty(t, buildOptions.Tags) + require.Equal(t, defaultApplyImageLayersTimeout, buildOptions.Timeout) + require.Equal(t, filepath.Join(workspace, "image.iid"), buildOptions.IidFile) + require.Equal(t, "FROM sha256:base-id\nADD layer0.tar /\n", string(readImageLayerTestFile(t, buildOptions.Dockerfile))) + return usvc_io.WriteFile(buildOptions.IidFile, []byte(expectedImageID+"\n"), osutil.PermissionOnlyOwnerReadWrite) + }) + + imageRef, applyErr := applyImageLayersFromDirectory( + context.Background(), + logr.Discard(), + ApplyImageLayersOptions{ + BaseImage: InspectedImage{Id: "sha256:base-id"}, + Layers: []ImageLayer{{ + Digest: "raw", + RawContents: base64.StdEncoding.EncodeToString([]byte("layer")), + }}, + }, + builder, + tempDirectory, + ) + + require.NoError(t, applyErr) + assert.Equal(t, expectedImageID, imageRef) + require.NotEmpty(t, workspace) + assertPathRemoved(t, workspace) + assertDirectoryEmpty(t, tempDirectory) +} + +func TestApplyImageLayersFromDirectoryRejectsMissingOrInvalidImageID(t *testing.T) { + testCases := []struct { + name string + writeImageID func(t *testing.T, path string) + errorContains string + }{ + { + name: "missing", + writeImageID: func(t *testing.T, path string) { + t.Helper() + require.NoError(t, os.Remove(path)) + }, + errorContains: "inspecting image ID file", + }, + { + name: "invalid", + writeImageID: func(t *testing.T, path string) { + t.Helper() + require.NoError(t, usvc_io.WriteFile(path, []byte("not-an-image-id"), osutil.PermissionOnlyOwnerReadWrite)) + }, + errorContains: "invalid image ID", + }, + { + name: "oversized", + writeImageID: func(t *testing.T, path string) { + t.Helper() + require.NoError(t, usvc_io.WriteFile(path, []byte(strings.Repeat("a", maxImageIDFileSize+1)), osutil.PermissionOnlyOwnerReadWrite)) + }, + errorContains: "exceeds 1024 bytes", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + tempDirectory := t.TempDir() + imageRef, applyErr := applyImageLayersFromDirectory( + context.Background(), + logr.Discard(), + ApplyImageLayersOptions{ + BaseImage: InspectedImage{Id: "sha256:base"}, + Layers: []ImageLayer{{ + Digest: "raw", + RawContents: base64.StdEncoding.EncodeToString([]byte("layer")), + }}, + }, + buildImageFunc(func(_ context.Context, buildOptions BuildImageOptions) error { + testCase.writeImageID(t, buildOptions.IidFile) + return nil + }), + tempDirectory, + ) + + require.Error(t, applyErr) + assert.Contains(t, applyErr.Error(), testCase.errorContains) + assert.Empty(t, imageRef) + assertDirectoryEmpty(t, tempDirectory) + }) + } +} + +func TestApplyImageLayersFromDirectoryReturnsBuilderFailureAndCleansUp(t *testing.T) { + t.Parallel() + + expectedErr := errors.New("builder failed") + tempDirectory := t.TempDir() + var workspace string + imageRef, applyErr := applyImageLayersFromDirectory( + context.Background(), + logr.Discard(), + ApplyImageLayersOptions{ + BaseImage: InspectedImage{Id: "sha256:base"}, + Layers: []ImageLayer{{ + Digest: "raw", + RawContents: base64.StdEncoding.EncodeToString([]byte("layer")), + }}, + Tag: "derived:tag", + }, + buildImageFunc(func(_ context.Context, buildOptions BuildImageOptions) error { + workspace = filepath.Dir(buildOptions.Context) + return expectedErr + }), + tempDirectory, + ) + + require.ErrorIs(t, applyErr, expectedErr) + assert.Empty(t, imageRef) + require.NotEmpty(t, workspace) + assertPathRemoved(t, workspace) + assertDirectoryEmpty(t, tempDirectory) +} + +func TestApplyImageLayersFromDirectoryHonorsBuilderCancellationAndCleansUp(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tempDirectory := t.TempDir() + var workspace string + imageRef, applyErr := applyImageLayersFromDirectory( + ctx, + logr.Discard(), + ApplyImageLayersOptions{ + BaseImage: InspectedImage{Id: "sha256:base"}, + Layers: []ImageLayer{{ + Digest: "raw", + RawContents: base64.StdEncoding.EncodeToString([]byte("layer")), + }}, + Tag: "derived:tag", + }, + buildImageFunc(func(_ context.Context, buildOptions BuildImageOptions) error { + workspace = filepath.Dir(buildOptions.Context) + cancel() + return nil + }), + tempDirectory, + ) + + require.ErrorIs(t, applyErr, context.Canceled) + assert.Empty(t, imageRef) + require.NotEmpty(t, workspace) + assertPathRemoved(t, workspace) + assertDirectoryEmpty(t, tempDirectory) +} + +func readImageLayerTestFile(t *testing.T, path string) []byte { + t.Helper() + + file, openErr := usvc_io.OpenFileReadOnly(path) + require.NoError(t, openErr) + contents, readErr := io.ReadAll(file) + closeErr := file.Close() + require.NoError(t, readErr) + require.NoError(t, closeErr) + return contents +} + +func assertPathRemoved(t *testing.T, path string) { + t.Helper() + + _, statErr := os.Lstat(path) + require.ErrorIs(t, statErr, os.ErrNotExist) +} + +func assertDirectoryEmpty(t *testing.T, path string) { + t.Helper() + + entries, readErr := os.ReadDir(path) + require.NoError(t, readErr) + assert.Empty(t, entries) +} diff --git a/internal/containers/runtimes/runtime.go b/internal/containers/runtimes/runtime.go index fa1a76ea..5c999b17 100644 --- a/internal/containers/runtimes/runtime.go +++ b/internal/containers/runtimes/runtime.go @@ -15,6 +15,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" ) @@ -25,6 +26,7 @@ var ( supportedRuntimes = map[flags.RuntimeFlagValue]ContainerOrchestratorFactory{ flags.DockerRuntime: docker.NewDockerCliOrchestrator, flags.PodmanRuntime: podman.NewPodmanCliOrchestrator, + flags.WslcRuntime: wslc.NewWslcCliOrchestrator, } ) @@ -51,22 +53,11 @@ func FindAvailableContainerRuntime(ctx context.Context, log logr.Logger, executo } for i := 0; i < len(supportedRuntimes); i++ { - supportedRuntime := <-runtimesCh - - switch { - case availableRuntime == nil: - // We haven't picked a runtime yet - availableRuntime = supportedRuntime - case !availableRuntime.status.Installed && supportedRuntime.status.Installed: - // Prefer a runtime that is installed over one that isn't - availableRuntime = supportedRuntime - case !availableRuntime.status.Running && supportedRuntime.status.Running: - // Prefer a runtime that is running over one that isn't - availableRuntime = supportedRuntime - case supportedRuntime.orchestrator.IsDefault() && supportedRuntime.status.Installed == availableRuntime.status.Installed && supportedRuntime.status.Running == availableRuntime.status.Running: - // Prefer the default runtime - availableRuntime = supportedRuntime + supportedRuntime, open := <-runtimesCh + if !open || supportedRuntime == nil { + return nil, fmt.Errorf("container runtime discovery ended without a result") } + availableRuntime = preferredRuntime(availableRuntime, supportedRuntime) } } else { orchestrator, runtimeErr := FindContainerRuntime(ctx, string(runtimeFlagValue), log, executor) @@ -84,6 +75,36 @@ func FindAvailableContainerRuntime(ctx context.Context, log logr.Logger, executo return availableRuntime.orchestrator, nil } +func preferredRuntime(current, candidate *runtimeSupport) *runtimeSupport { + switch { + case current == nil: + return candidate + case !current.status.Installed && candidate.status.Installed: + return candidate + case !current.status.Running && candidate.status.Running: + return candidate + case current.status.Installed == candidate.status.Installed && + current.status.Running == candidate.status.Running && + runtimePriority(candidate.orchestrator.Name()) < runtimePriority(current.orchestrator.Name()): + return candidate + default: + return current + } +} + +func runtimePriority(runtimeName string) int { + switch flags.RuntimeFlagValue(runtimeName) { + case flags.DockerRuntime: + return 0 + case flags.PodmanRuntime: + return 1 + case flags.WslcRuntime: + return 2 + default: + return 3 + } +} + func FindContainerRuntime(ctx context.Context, runtimeName string, log logr.Logger, executor process.Executor) (containers.ContainerOrchestrator, error) { runtimeName = strings.TrimSpace(strings.ToLower(runtimeName)) if runtimeName == "" { diff --git a/internal/containers/runtimes/runtime_test.go b/internal/containers/runtimes/runtime_test.go new file mode 100644 index 00000000..d01338e1 --- /dev/null +++ b/internal/containers/runtimes/runtime_test.go @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package runtimes + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/go-logr/logr" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/internal/containers/flags" + internal_testutil "github.com/microsoft/dcp/internal/testutil" + "github.com/microsoft/dcp/pkg/process" +) + +type selectionTestOrchestrator struct { + containers.ContainerOrchestrator + name string + status containers.ContainerRuntimeStatus +} + +func (orchestrator selectionTestOrchestrator) Name() string { + return orchestrator.name +} + +func (orchestrator selectionTestOrchestrator) CheckStatus(context.Context, containers.CachedRuntimeStatusUsage) containers.ContainerRuntimeStatus { + return orchestrator.status +} + +func TestRuntimeSelectionPriorities(t *testing.T) { + t.Parallel() + + absent := containers.ContainerRuntimeStatus{} + stopped := containers.ContainerRuntimeStatus{Installed: true} + healthy := containers.ContainerRuntimeStatus{Installed: true, Running: true} + + for _, testCase := range []struct { + name string + docker containers.ContainerRuntimeStatus + podman containers.ContainerRuntimeStatus + wslc containers.ContainerRuntimeStatus + want string + }{ + {name: "all healthy", docker: healthy, podman: healthy, wslc: healthy, want: "docker"}, + {name: "podman and wslc", docker: absent, podman: healthy, wslc: healthy, want: "podman"}, + {name: "wslc only", docker: absent, podman: absent, wslc: healthy, want: "wslc"}, + {name: "wslc healthy others stopped", docker: stopped, podman: stopped, wslc: healthy, want: "wslc"}, + {name: "podman healthy docker stopped", docker: stopped, podman: healthy, wslc: healthy, want: "podman"}, + {name: "docker healthy wslc stopped", docker: healthy, podman: absent, wslc: stopped, want: "docker"}, + {name: "all stopped", docker: stopped, podman: stopped, wslc: stopped, want: "docker"}, + {name: "installed podman and wslc", docker: absent, podman: stopped, wslc: stopped, want: "podman"}, + {name: "only wslc installed", docker: absent, podman: absent, wslc: stopped, want: "wslc"}, + {name: "none installed", docker: absent, podman: absent, wslc: absent, want: "docker"}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + candidates := []*runtimeSupport{ + {orchestrator: selectionTestOrchestrator{name: "docker"}, status: testCase.docker}, + {orchestrator: selectionTestOrchestrator{name: "podman"}, status: testCase.podman}, + {orchestrator: selectionTestOrchestrator{name: "wslc"}, status: testCase.wslc}, + } + for _, order := range [][3]int{ + {0, 1, 2}, {0, 2, 1}, {1, 0, 2}, + {1, 2, 0}, {2, 0, 1}, {2, 1, 0}, + } { + var selected *runtimeSupport + for _, index := range order { + selected = preferredRuntime(selected, candidates[index]) + } + require.NotNil(t, selected) + require.Equal(t, testCase.want, selected.orchestrator.Name(), "discovery order: %v", order) + } + }) + } +} + +func TestRegisteredWSLCFactory(t *testing.T) { + t.Parallel() + + executor := internal_testutil.NewTestProcessExecutor(t.Context()) + t.Cleanup(func() { require.NoError(t, executor.Close()) }) + factory := supportedRuntimes[flags.WslcRuntime] + require.NotNil(t, factory) + + orchestrator := factory(logr.Discard(), executor) + require.Equal(t, "wslc", orchestrator.Name()) + require.False(t, orchestrator.IsDefault()) + require.Empty(t, orchestrator.ContainerHost()) +} + +func TestExplicitWSLCSelectionDoesNotFallBack(t *testing.T) { + originalFactories := supportedRuntimes + originalRuntime := flags.GetRuntimeFlagValue() + flagSet := pflag.NewFlagSet(t.Name(), pflag.ContinueOnError) + flags.EnsureRuntimeFlag(flagSet) + t.Cleanup(func() { + supportedRuntimes = originalFactories + require.NoError(t, flagSet.Set(flags.RuntimeFlagName, string(originalRuntime))) + }) + require.NoError(t, flagSet.Set(flags.RuntimeFlagName, "wslc")) + + for _, healthy := range []bool{true, false} { + var factoryCalls atomic.Int32 + supportedRuntimes = make(map[flags.RuntimeFlagValue]ContainerOrchestratorFactory) + for _, runtimeName := range []flags.RuntimeFlagValue{flags.DockerRuntime, flags.PodmanRuntime, flags.WslcRuntime} { + supportedRuntimes[runtimeName] = func(logr.Logger, process.Executor) containers.ContainerOrchestrator { + factoryCalls.Add(1) + return selectionTestOrchestrator{ + name: string(runtimeName), + status: containers.ContainerRuntimeStatus{ + Installed: true, + Running: runtimeName != flags.WslcRuntime || healthy, + }, + } + } + } + + orchestrator, findErr := FindAvailableContainerRuntime(t.Context(), logr.Discard(), nil) + require.NoError(t, findErr) + require.Equal(t, "wslc", orchestrator.Name()) + require.Equal(t, healthy, orchestrator.CheckStatus(t.Context(), containers.IgnoreCachedRuntimeStatus).IsHealthy()) + require.Equal(t, int32(1), factoryCalls.Load()) + } +} + +func TestFindContainerRuntimeRejectsInvalidName(t *testing.T) { + t.Parallel() + + for _, name := range []string{"", " ", "unknown"} { + orchestrator, findErr := FindContainerRuntime(context.Background(), name, logr.Discard(), nil) + require.Error(t, findErr) + require.Nil(t, orchestrator) + } +} diff --git a/internal/dcp/commands/info.go b/internal/dcp/commands/info.go index 2cca7522..2bf8ca32 100644 --- a/internal/dcp/commands/info.go +++ b/internal/dcp/commands/info.go @@ -51,10 +51,10 @@ func NewInfoCommand(log logr.Logger) (*cobra.Command, error) { } type containerRuntime struct { - // Name of the container runtime (i.e. docker, podman) + // Name of the container runtime (docker, podman, or wslc). Runtime string `json:"runtime"` - // Default hostname within a container for accessing the host machine network + // Default container-to-host address, or an empty string if unsupported. HostName string `json:"hostName"` // Is the runtime installed? diff --git a/internal/dcp/commands/info_test.go b/internal/dcp/commands/info_test.go new file mode 100644 index 00000000..00137b7a --- /dev/null +++ b/internal/dcp/commands/info_test.go @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package commands + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestContainerRuntimeInfoPreservesUnsupportedHostAddress(t *testing.T) { + t.Parallel() + + info := containerRuntime{Runtime: "wslc", Installed: true, Running: true} + encoded, marshalErr := json.Marshal(info) + require.NoError(t, marshalErr) + require.JSONEq(t, `{"runtime":"wslc","hostName":"","installed":true,"running":true}`, string(encoded)) +} + +func TestContainerRuntimeInfoPreservesSupportedHostAddress(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + runtime string + host string + }{ + {runtime: "docker", host: "host.docker.internal"}, + {runtime: "podman", host: "host.containers.internal"}, + } { + t.Run(testCase.runtime, func(t *testing.T) { + t.Parallel() + + info := containerRuntime{ + Runtime: testCase.runtime, HostName: testCase.host, + Installed: true, Running: true, + } + encoded, marshalErr := json.Marshal(info) + require.NoError(t, marshalErr) + var decoded containerRuntime + require.NoError(t, json.Unmarshal(encoded, &decoded)) + require.Equal(t, info, decoded) + }) + } +} diff --git a/internal/docker/cli_orchestrator.go b/internal/docker/cli_orchestrator.go index a9516f84..cf3639c7 100644 --- a/internal/docker/cli_orchestrator.go +++ b/internal/docker/cli_orchestrator.go @@ -1023,56 +1023,15 @@ func (dco *DockerCliOrchestrator) CreateFiles(ctx context.Context, options conta args = append(args, options.Container+":/") - tarWriter := usvc_io.NewTarWriter() - - certificateHashes := []string{} - for _, item := range options.Entries { - switch item.Type { - case containers.FileSystemEntryTypeDir: - if addDirectoryErr := containers.AddDirectoryToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, dco.log); addDirectoryErr != nil { - return addDirectoryErr - } - case containers.FileSystemEntryTypeSymlink: - if addSymlinkErr := containers.AddSymlinkToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, dco.log); addSymlinkErr != nil { - if item.ContinueOnError { - dco.log.Error(addSymlinkErr, "Failed to add symlink to tar archive, continuing", "SymLink", item) - } else { - return addSymlinkErr - } - } - case containers.FileSystemEntryTypeOpenSSL: - hash, addCertErr := containers.AddCertificateToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, certificateHashes, dco.log) - if addCertErr != nil { - if item.ContinueOnError { - dco.log.Error(addCertErr, "Failed to add a certificate to the tar file, but continueOnError is set", "Certificate", item) - } else { - return 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 := containers.AddFileToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, dco.log); addFileErr != nil { - if item.ContinueOnError { - dco.log.Error(addFileErr, "Failed to add a file to the tar file, but continueOnError is set", "File", item) - } else { - return addFileErr - } - } - } + buffer, archiveErr := containers.CreateFilesArchive(ctx, dco.log, options) + if archiveErr != nil { + return archiveErr } - - if tarWriter.Empty() { + if buffer == nil { // Can happen if all ContinueOnError items fail return nil } - buffer, bufferErr := tarWriter.Buffer() - if bufferErr != nil { - return bufferErr - } - cmd := makeDockerCommand(args...) cmd.Stdin = buffer _, errBuf, err := dco.runBufferedDockerCommand(ctx, "CopyFile", cmd, nil, nil, ordinaryDockerCommandTimeout) diff --git a/internal/podman/cli_orchestrator.go b/internal/podman/cli_orchestrator.go index 5d5a797b..4f9ba328 100644 --- a/internal/podman/cli_orchestrator.go +++ b/internal/podman/cli_orchestrator.go @@ -908,56 +908,15 @@ func (pco *PodmanCliOrchestrator) CreateFiles(ctx context.Context, options conta args = append(args, options.Container+":/") - tarWriter := usvc_io.NewTarWriter() - - certificateHashes := []string{} - for _, item := range options.Entries { - switch item.Type { - case containers.FileSystemEntryTypeDir: - if addDirectoryErr := containers.AddDirectoryToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, pco.log); addDirectoryErr != nil { - return addDirectoryErr - } - case containers.FileSystemEntryTypeSymlink: - if addSymlinkErr := containers.AddSymlinkToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, pco.log); addSymlinkErr != nil { - if item.ContinueOnError { - pco.log.Error(addSymlinkErr, "Failed to add symlink to tar archive, continuing", "SymLink", item) - } else { - return addSymlinkErr - } - } - case containers.FileSystemEntryTypeOpenSSL: - hash, addCertErr := containers.AddCertificateToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, certificateHashes, pco.log) - if addCertErr != nil { - if item.ContinueOnError { - pco.log.Error(addCertErr, "Failed to add a certificate to the tar file, but continueOnError is set", "Certificate", item) - } else { - return 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 := containers.AddFileToTar(tarWriter, options.Destination, options.DefaultOwner, options.DefaultGroup, options.Umask, item, options.ModTime, pco.log); addFileErr != nil { - if item.ContinueOnError { - pco.log.Error(addFileErr, "Failed to add a file to the tar file, but continueOnError is set", "File", item) - } else { - return addFileErr - } - } - } + buffer, archiveErr := containers.CreateFilesArchive(ctx, pco.log, options) + if archiveErr != nil { + return archiveErr } - - if tarWriter.Empty() { + if buffer == nil { // Can happen if all ContinueOnError items fail return nil } - buffer, bufferErr := tarWriter.Buffer() - if bufferErr != nil { - return bufferErr - } - var cmd *exec.Cmd if runtime.GOOS == "windows" { // TODO: Remove this workaround once podman cli supports copy via stdio on Windows diff --git a/internal/testutil/containertest/capabilities.go b/internal/testutil/containertest/capabilities.go new file mode 100644 index 00000000..f951fa2f --- /dev/null +++ b/internal/testutil/containertest/capabilities.go @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package containertest + +import ( + "testing" + + container_flags "github.com/microsoft/dcp/internal/containers/flags" +) + +// SkipIfNativeRuntimeEventsUnavailable skips tests that require unavailable native event watches. +func SkipIfNativeRuntimeEventsUnavailable(t *testing.T, runtime Runtime) { + t.Helper() + + if runtime.Name == string(container_flags.WslcRuntime) { + t.Skip("WSLC CLI 2.9.11 does not expose native events; remove this gate when native container/network watches are implemented") + } +} diff --git a/internal/testutil/containertest/capabilities_test.go b/internal/testutil/containertest/capabilities_test.go new file mode 100644 index 00000000..7ad0aeb7 --- /dev/null +++ b/internal/testutil/containertest/capabilities_test.go @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package containertest + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNativeEventGateOnlySkipsWSLC(t *testing.T) { + t.Parallel() + + for _, runtimeName := range []string{"docker", "podman", "wslc"} { + var skipped bool + t.Run(runtimeName, func(t *testing.T) { + t.Cleanup(func() { skipped = t.Skipped() }) + SkipIfNativeRuntimeEventsUnavailable(t, Runtime{Name: runtimeName}) + }) + require.Equal(t, runtimeName == "wslc", skipped) + } +} + +func TestRuntimeRegistrationsIncludeSchedulingAndRecovery(t *testing.T) { + t.Parallel() + + require.Contains(t, supportedRuntimeNames, "wslc") + for _, runtimeName := range supportedRuntimeNames { + require.NotNil(t, runtimeTestSlots[runtimeName], "runtime %q has no test slots", runtimeName) + require.Positive(t, cap(runtimeTestSlots[runtimeName])) + require.NotNil(t, runtimeRecovery[runtimeName], "runtime %q has no recovery state", runtimeName) + } +} diff --git a/internal/testutil/containertest/runtime.go b/internal/testutil/containertest/runtime.go index 8e93116f..5a818b15 100644 --- a/internal/testutil/containertest/runtime.go +++ b/internal/testutil/containertest/runtime.go @@ -26,11 +26,13 @@ const runtimeDetectionTimeout = 45 * time.Second var supportedRuntimeNames = []string{ string(container_flags.DockerRuntime), string(container_flags.PodmanRuntime), + string(container_flags.WslcRuntime), } var runtimeTestSlots = map[string]chan struct{}{ string(container_flags.DockerRuntime): make(chan struct{}, 8), string(container_flags.PodmanRuntime): make(chan struct{}, 4), + string(container_flags.WslcRuntime): make(chan struct{}, 4), } type runtimeDetectionResult struct { @@ -55,6 +57,7 @@ var ( runtimeRecovery = map[string]*runtimeRecoveryState{ string(container_flags.DockerRuntime): {}, string(container_flags.PodmanRuntime): {}, + string(container_flags.WslcRuntime): {}, } ) diff --git a/internal/wslc/cli.go b/internal/wslc/cli.go new file mode 100644 index 00000000..e63091b6 --- /dev/null +++ b/internal/wslc/cli.go @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "errors" + "fmt" + "io" + "os/exec" + "regexp" + "sort" + "strings" + "time" + + "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/pkg/maps" + "github.com/microsoft/dcp/pkg/process" +) + +var ( + containerNotFoundMatch = containers.NewCliErrorMatch( + regexp.MustCompile(`(?i)container\s+['"].+['"]\s+not found`), + errors.Join(containers.ErrNotFound, fmt.Errorf("container not found")), + ) + imageNotFoundMatch = containers.NewCliErrorMatch( + regexp.MustCompile(`(?i)image\s+['"].+['"]\s+not found`), + errors.Join(containers.ErrNotFound, fmt.Errorf("image not found")), + ) + networkNotFoundMatch = containers.NewCliErrorMatch( + regexp.MustCompile(`(?i)network not found:\s*['"].+['"]`), + errors.Join(containers.ErrNotFound, fmt.Errorf("network not found")), + ) + volumeNotFoundMatch = containers.NewCliErrorMatch( + regexp.MustCompile(`(?i)volume not found:\s*['"].+['"]`), + errors.Join(containers.ErrNotFound, fmt.Errorf("volume not found")), + ) + alreadyExistsMatch = containers.NewCliErrorMatch( + regexp.MustCompile(`(?i)(already exists|already in use|already connected|already attached)`), + containers.ErrAlreadyExists, + ) + objectInUseMatch = containers.NewCliErrorMatch( + regexp.MustCompile(`(?i)(is in use|being used|active endpoints|is running|running container)`), + containers.ErrObjectInUse, + ) + allocationFailureMatch = containers.NewCliErrorMatch( + regexp.MustCompile(`(?i)(address pool|could not allocate|no available subnet|port is already allocated|address already in use|failed to bind)`), + containers.ErrCouldNotAllocate, + ) + runtimeUnavailableMatch = containers.NewCliErrorMatch( + regexp.MustCompile( + `(?i)(`+ + `no active (?:wslc )?(?:default )?session|`+ + `(?:session manager|default session|wslc control endpoint|session control endpoint).*`+ + `(?:not running|unavailable|not found|failed to connect|connection refused)|`+ + `(?:failed to connect|connection refused).*`+ + `(?:session manager|default session|wslc control endpoint|session control endpoint)`+ + `)`, + ), + containers.ErrRuntimeNotHealthy, + ) +) + +func makeWslcCommand(args ...string) *exec.Cmd { + cmd := exec.Command("wslc", args...) + 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) +} + +func (wco *WslcCliOrchestrator) runBufferedWslcCommand( + ctx context.Context, + commandName string, + cmd *exec.Cmd, + stdoutCloser io.WriteCloser, + stderrCloser io.WriteCloser, + timeout time.Duration, +) (*bytes.Buffer, *bytes.Buffer, error) { + return wco.runBufferedWslcCommandInternal( + ctx, + commandName, + cmd, + stdoutCloser, + stderrCloser, + timeout, + true, + ) +} + +func (wco *WslcCliOrchestrator) runBufferedWslcCommandInternal( + ctx context.Context, + commandName string, + cmd *exec.Cmd, + stdoutCloser io.WriteCloser, + stderrCloser io.WriteCloser, + timeout time.Duration, + closeStreams bool, +) (*bytes.Buffer, *bytes.Buffer, error) { + if timeout <= 0 { + return nil, nil, fmt.Errorf("timeout for WSLC command %q must be positive", commandName) + } + + effectiveCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + stdoutBuffer := new(bytes.Buffer) + if stdoutCloser == nil { + cmd.Stdout = stdoutBuffer + } else { + if closeStreams { + defer func() { + _ = stdoutCloser.Close() + }() + } + cmd.Stdout = io.MultiWriter(stdoutCloser, stdoutBuffer) + } + + stderrBuffer := new(bytes.Buffer) + if stderrCloser == nil { + cmd.Stderr = stderrBuffer + } else { + if closeStreams { + defer func() { + _ = stderrCloser.Close() + }() + } + cmd.Stderr = io.MultiWriter(stderrCloser, stderrBuffer) + } + if contextErr := effectiveCtx.Err(); contextErr != nil { + return stdoutBuffer, stderrBuffer, contextErr + } + + exitHandler := process.NewConcurrentProcessExitHandler() + wco.log.V(1).Info("Running WSLC command", "Command", cmd.String()) + _, startWaitForExit, startErr := wco.executor.StartProcess( + effectiveCtx, + cmd, + exitHandler, + process.CreationFlagsNone, + nil, + ) + if startErr != nil { + return stdoutBuffer, stderrBuffer, fmt.Errorf("failed to start WSLC command %q: %w", commandName, startErr) + } + startWaitForExit() + + <-exitHandler.Exited() + exitInfo := exitHandler.ExitInfo() + var commandErr error + if exitInfo.Err != nil { + commandErr = exitInfo.Err + } + if exitInfo.ExitCode != 0 { + commandErr = errors.Join( + commandErr, + fmt.Errorf("wslc command %q returned non-zero exit code %d", commandName, exitInfo.ExitCode), + ) + } + + return stdoutBuffer, stderrBuffer, commandErr +} + +func (wco *WslcCliOrchestrator) startStreamingWslcCommand( + ctx context.Context, + commandName string, + cmd *exec.Cmd, + stdout io.Writer, + stderr io.Writer, +) (*process.ConcurrentProcessExitHandler, error) { + if contextErr := ctx.Err(); contextErr != nil { + return nil, contextErr + } + + cmd.Stdout = stdout + cmd.Stderr = stderr + + exitHandler := process.NewConcurrentProcessExitHandler() + wco.log.V(1).Info("Running WSLC command", "Command", cmd.String()) + _, startWaitForExit, startErr := wco.executor.StartProcess( + ctx, + cmd, + exitHandler, + process.CreationFlagEnsureKillOnDispose, + nil, + ) + if startErr != nil { + return nil, fmt.Errorf("failed to start WSLC command %q: %w", commandName, startErr) + } + startWaitForExit() + return exitHandler, nil +} + +func normalizeCliErrors(errBuf *bytes.Buffer, extraMatches ...containers.ErrorMatch) error { + matches := append(extraMatches, runtimeUnavailableMatch) + return containers.NormalizeCliErrors(errBuf, matches...) +} + +func appendLabelArgs( + args []string, + labels []containers.Label, + objectKind string, +) ([]string, error) { + labelValues := maps.SliceToMap(labels, func(label containers.Label) (string, string) { + return label.Key, label.Value + }) + labelKeys := make([]string, 0, len(labelValues)) + for key := range labelValues { + labelKeys = append(labelKeys, key) + } + sort.Strings(labelKeys) + + for _, key := range labelKeys { + if key == "" { + return nil, fmt.Errorf("%s label key cannot be empty", objectKind) + } + args = append(args, "--label", key+"="+labelValues[key]) + } + return args, nil +} + +func parseSingleIdentifier(buffer *bytes.Buffer) (string, error) { + if buffer == nil { + return "", fmt.Errorf("wslc command did not return an object identifier") + } + + lines := nonEmptyLines(buffer.Bytes()) + if len(lines) != 1 { + return "", fmt.Errorf("wslc command output did not contain exactly one identifier: %q", buffer.String()) + } + if strings.ContainsAny(lines[0], " \t") { + return "", fmt.Errorf("wslc command returned an invalid identifier %q", lines[0]) + } + return lines[0], nil +} + +func nonEmptyLines(data []byte) []string { + rawLines := bytes.Split(data, []byte{'\n'}) + lines := make([]string, 0, len(rawLines)) + for _, rawLine := range rawLines { + line := strings.TrimSpace(string(rawLine)) + if line != "" { + lines = append(lines, line) + } + } + return lines +} + +func incompleteError(objectKind string, actual int, expected int) error { + if actual >= expected { + return nil + } + return errors.Join( + containers.ErrIncomplete, + fmt.Errorf("only %d out of %d %s were successfully processed", actual, expected, objectKind), + ) +} + +func closeWriteCloser(closer io.WriteCloser) { + if closer != nil { + _ = closer.Close() + } +} + +var _ containers.CLICommandRunner = (*WslcCliOrchestrator)(nil) diff --git a/internal/wslc/cli_test.go b/internal/wslc/cli_test.go new file mode 100644 index 00000000..29fb4c06 --- /dev/null +++ b/internal/wslc/cli_test.go @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/microsoft/dcp/internal/containers" +) + +func TestDecodeJSONLinesAllowsEmptyListingsAndPreservesValidLines(t *testing.T) { + t.Parallel() + + empty, emptyErr := decodeJSONLines[wslcListedVolume](bytes.NewBuffer(nil)) + require.NoError(t, emptyErr) + require.Empty(t, empty) + + decoded, decodeErr := decodeJSONLines[wslcListedVolume](bytes.NewBufferString( + "{\"Name\":\"first\"}\nnot-json\n{\"Name\":\"second\"}\n", + )) + require.ErrorIs(t, decodeErr, containers.ErrUnmarshalling) + require.Equal(t, []wslcListedVolume{{Name: "first"}, {Name: "second"}}, decoded) +} + +func TestDecodeJSONArrayPreservesValidObjects(t *testing.T) { + t.Parallel() + + decoded, decodeErr := decodeJSONArray[wslcInspectedImage](bytes.NewBufferString( + `[{"Id":"sha256:first"},{"Id":42},{"Id":"sha256:second"}]`, + )) + + require.ErrorIs(t, decodeErr, containers.ErrUnmarshalling) + require.Len(t, decoded, 2) + require.Equal(t, "sha256:first", decoded[0].ID) + require.Equal(t, "sha256:second", decoded[1].ID) +} + +func TestNormalizeCliErrorsRecognizesWslcMissingObjects(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + message string + match containers.ErrorMatch + }{ + { + name: "container", + message: "Container 'missing-container' not found.\n", + match: containerNotFoundMatch, + }, + { + name: "image", + message: "Image 'missing-image' not found.\n", + match: imageNotFoundMatch, + }, + { + name: "network", + message: "Network not found: 'missing-network'\n", + match: networkNotFoundMatch, + }, + { + name: "volume", + message: "Volume not found: 'missing-volume'\n", + match: volumeNotFoundMatch, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + normalizedErr := normalizeCliErrors(bytes.NewBufferString(testCase.message), testCase.match) + require.ErrorIs(t, normalizedErr, containers.ErrNotFound) + require.False(t, errors.Is(normalizedErr, containers.ErrUnmatched)) + }) + } +} + +func TestNormalizeCliErrorsRestrictsRuntimeHealthClassification(t *testing.T) { + t.Parallel() + + registryErr := normalizeCliErrors(bytes.NewBufferString( + "failed to connect to registry.example.test: connection refused\n", + )) + require.ErrorIs(t, registryErr, containers.ErrUnmatched) + require.NotErrorIs(t, registryErr, containers.ErrRuntimeNotHealthy) + + sessionManagerErr := normalizeCliErrors(bytes.NewBufferString( + "failed to connect to WSLC session manager: connection refused\n", + )) + require.ErrorIs(t, sessionManagerErr, containers.ErrRuntimeNotHealthy) + require.NotErrorIs(t, sessionManagerErr, containers.ErrUnmatched) + + defaultSessionErr := normalizeCliErrors(bytes.NewBufferString( + "default session is unavailable\n", + )) + require.ErrorIs(t, defaultSessionErr, containers.ErrRuntimeNotHealthy) +} + +func TestParseSingleIdentifierRejectsEmptyAndAmbiguousOutput(t *testing.T) { + t.Parallel() + + identifier, identifierErr := parseSingleIdentifier(bytes.NewBufferString("\r\n id-value \r\n")) + require.NoError(t, identifierErr) + require.Equal(t, "id-value", identifier) + + _, emptyErr := parseSingleIdentifier(bytes.NewBuffer(nil)) + require.Error(t, emptyErr) + + _, multipleErr := parseSingleIdentifier(bytes.NewBufferString("first\nsecond\n")) + require.Error(t, multipleErr) +} + +func TestWslcBoolAcceptsStringsAndBooleans(t *testing.T) { + t.Parallel() + + decoded, decodeErr := decodeJSONLines[wslcListedNetwork](bytes.NewBufferString( + "{\"ID\":\"one\",\"Name\":\"first\",\"IPv6\":\"true\",\"Internal\":false}\n", + )) + + require.NoError(t, decodeErr) + require.Len(t, decoded, 1) + require.True(t, bool(decoded[0].IPv6)) + require.False(t, bool(decoded[0].Internal)) +} diff --git a/internal/wslc/containers.go b/internal/wslc/containers.go new file mode 100644 index 00000000..8a8fc704 --- /dev/null +++ b/internal/wslc/containers.go @@ -0,0 +1,865 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/internal/networking" + "github.com/microsoft/dcp/internal/termpty" + usvc_io "github.com/microsoft/dcp/pkg/io" + "github.com/microsoft/dcp/pkg/process" +) + +func applyCreateContainerOptions(args []string, options containers.CreateContainerOptions) ([]string, error) { + if options.Image == "" { + return nil, fmt.Errorf("must specify an image") + } + if options.RestartPolicy != "" && options.RestartPolicy != containers.RestartPolicyNone { + return nil, fmt.Errorf("wslc does not support restart policy %q", options.RestartPolicy) + } + if options.Healthcheck.Interval < 0 || + options.Healthcheck.Timeout < 0 || + options.Healthcheck.StartPeriod < 0 || + options.Healthcheck.StartInterval < 0 || + options.Healthcheck.Retries < 0 { + return nil, fmt.Errorf("health-check durations and retry count cannot be negative") + } + if options.Healthcheck.StartInterval > 0 { + return nil, fmt.Errorf("wslc does not support health-check start intervals") + } + if len(options.Healthcheck.Command) == 0 && + (options.Healthcheck.Interval > 0 || + options.Healthcheck.Timeout > 0 || + options.Healthcheck.Retries > 0 || + options.Healthcheck.StartPeriod > 0) { + return nil, fmt.Errorf("health-check options require a health-check command") + } + + if options.Name != "" { + args = append(args, "--name", options.Name) + } + + for _, network := range options.Networks { + if network.Name == "" { + return nil, fmt.Errorf("container network name cannot be empty") + } + networkValue := network.Name + if len(network.Aliases) > 0 { + networkValue = "name=" + network.Name + for _, alias := range network.Aliases { + if alias == "" { + return nil, fmt.Errorf("container network alias cannot be empty") + } + networkValue += ",alias=" + alias + } + } + args = append(args, "--network", networkValue) + } + + for _, mount := range options.VolumeMounts { + if mount.Type != containers.BindMount && mount.Type != containers.NamedVolumeMount { + return nil, fmt.Errorf("unsupported container mount type %q", mount.Type) + } + if mount.Target == "" { + return nil, fmt.Errorf("container mount target cannot be empty") + } + + mountValue := fmt.Sprintf("type=%s", mount.Type) + if mount.Source != "" { + mountValue += ",src=" + mount.Source + } + mountValue += ",target=" + mount.Target + if mount.ReadOnly { + mountValue += ",readonly" + } + args = append(args, "--mount", mountValue) + } + + for _, port := range options.Ports { + if port.ContainerPort <= 0 { + return nil, fmt.Errorf("container port must be positive") + } + if port.HostPort < 0 { + return nil, fmt.Errorf("host port cannot be negative") + } + + hostIP := port.HostIP + if hostIP == "" { + hostIP = networking.IPv4LocalhostDefaultAddress + } + + hostPort := "" + if port.HostPort > 0 { + hostPort = fmt.Sprintf("%d", port.HostPort) + } + portValue := fmt.Sprintf("%s:%s:%d", hostIP, hostPort, port.ContainerPort) + if port.Protocol != "" { + portValue += "/" + port.Protocol + } + args = append(args, "--publish", portValue) + } + + for _, envVar := range options.Env { + if envVar.Name == "" { + return nil, fmt.Errorf("container environment variable name cannot be empty") + } + args = append(args, "--env", envVar.Name+"="+envVar.Value) + } + for _, envFile := range options.EnvFiles { + if envFile == "" { + return nil, fmt.Errorf("container environment file path cannot be empty") + } + args = append(args, "--env-file", envFile) + } + var labelArgsErr error + args, labelArgsErr = appendLabelArgs(args, options.Labels, "container") + if labelArgsErr != nil { + return nil, labelArgsErr + } + + switch options.PullPolicy { + case "", containers.PullPolicyAlways, containers.PullPolicyMissing, containers.PullPolicyNever: + default: + return nil, fmt.Errorf("unsupported image pull policy %q", options.PullPolicy) + } + if options.PullPolicy != "" { + args = append(args, "--pull", string(options.PullPolicy)) + } + + if options.Entrypoint != "" { + args = append(args, "--entrypoint", options.Entrypoint) + } + + if len(options.Healthcheck.Command) > 0 { + args = append(args, "--health-cmd", strings.Join(options.Healthcheck.Command, " ")) + if options.Healthcheck.Interval > 0 { + args = append(args, "--health-interval", options.Healthcheck.Interval.String()) + } else { + args = append(args, "--health-interval", "30s") + } + if options.Healthcheck.Timeout > 0 { + args = append(args, "--health-timeout", options.Healthcheck.Timeout.String()) + } + if options.Healthcheck.Retries > 0 { + args = append(args, "--health-retries", fmt.Sprintf("%d", options.Healthcheck.Retries)) + } else { + args = append(args, "--health-retries", "3") + } + if options.Healthcheck.StartPeriod > 0 { + args = append(args, "--health-start-period", options.Healthcheck.StartPeriod.String()) + } + } + + if options.AttachTerminal { + args = append(args, "--interactive", "--tty") + } + + args = append(args, options.RunArgs...) + return args, nil +} + +func (wco *WslcCliOrchestrator) CreateContainer(ctx context.Context, options containers.CreateContainerOptions) (string, error) { + resolvedOptions, resolveNetworksErr := wco.resolveCreateContainerNetworks(ctx, options) + if resolveNetworksErr != nil { + return "", resolveNetworksErr + } + + args, applyErr := applyCreateContainerOptions([]string{"container", "create"}, resolvedOptions) + if applyErr != nil { + return "", applyErr + } + args = append(args, resolvedOptions.Image) + args = append(args, resolvedOptions.Command...) + + timeout := resolvedOptions.Timeout + if timeout == 0 { + timeout = defaultCreateTimeout + } + + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "CreateContainer", + cmd, + resolvedOptions.StdOutStream, + resolvedOptions.StdErrStream, + timeout, + ) + if runErr != nil { + operationErr := errors.Join( + runErr, + normalizeCliErrors(errBuf, containerNotFoundMatch, imageNotFoundMatch, alreadyExistsMatch, allocationFailureMatch), + ) + containerID, idErr := parseSingleIdentifier(outBuf) + if idErr == nil { + return containerID, operationErr + } + return "", operationErr + } + + return parseSingleIdentifier(outBuf) +} + +func (wco *WslcCliOrchestrator) RunContainer(ctx context.Context, options containers.RunContainerOptions) (string, error) { + resolvedOptions, resolveNetworksErr := wco.resolveCreateContainerNetworks(ctx, options.CreateContainerOptions) + if resolveNetworksErr != nil { + return "", resolveNetworksErr + } + + createOptions := resolvedOptions + runArgs := createOptions.RunArgs + createOptions.RunArgs = nil + args, applyErr := applyCreateContainerOptions([]string{"container", "run"}, createOptions) + if applyErr != nil { + return "", applyErr + } + args = append(args, "--detach") + args = append(args, runArgs...) + args = append(args, resolvedOptions.Image) + args = append(args, resolvedOptions.Command...) + + timeout := resolvedOptions.Timeout + if timeout == 0 { + timeout = defaultRunTimeout + } + + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "RunContainer", + cmd, + resolvedOptions.StdOutStream, + resolvedOptions.StdErrStream, + timeout, + ) + if runErr != nil { + operationErr := errors.Join( + runErr, + normalizeCliErrors(errBuf, containerNotFoundMatch, imageNotFoundMatch, alreadyExistsMatch, allocationFailureMatch), + ) + containerID, idErr := parseSingleIdentifier(outBuf) + if idErr == nil { + return containerID, operationErr + } + return "", operationErr + } + + return parseSingleIdentifier(outBuf) +} + +func (wco *WslcCliOrchestrator) resolveCreateContainerNetworks( + ctx context.Context, + options containers.CreateContainerOptions, +) (containers.CreateContainerOptions, error) { + if len(options.Networks) == 0 { + return options, nil + } + + resolvedNetworks := make([]containers.CreateContainerNetworkOptions, len(options.Networks)) + for index, requestedNetwork := range options.Networks { + resolvedNetwork, resolveErr := wco.resolveNetwork(ctx, requestedNetwork.Name) + if resolveErr != nil { + return containers.CreateContainerOptions{}, fmt.Errorf( + "resolving initial container network %q: %w", + requestedNetwork.Name, + resolveErr, + ) + } + + resolvedNetworks[index] = containers.CreateContainerNetworkOptions{ + Name: resolvedNetwork.Name, + Aliases: append([]string(nil), requestedNetwork.Aliases...), + } + } + + options.Networks = resolvedNetworks + return options, nil +} + +func applyListContainersOptions(args []string, options containers.ListContainersOptions) []string { + if options.All { + args = append(args, "--all") + } + for _, label := range options.Filters.LabelFilters { + filter := "label=" + label.Key + if label.Value != "" { + filter += "=" + label.Value + } + args = append(args, "--filter", filter) + } + for _, network := range options.Filters.NetworkFilters { + args = append(args, "--filter", "network="+network) + } + return args +} + +func (wco *WslcCliOrchestrator) ListContainers(ctx context.Context, options containers.ListContainersOptions) ([]containers.ListedContainer, error) { + resolvedOptions, resolveFiltersErr := wco.resolveListContainerNetworkFilters(ctx, options) + if resolveFiltersErr != nil { + return nil, resolveFiltersErr + } + + args := applyListContainersOptions([]string{"container", "list", "--no-trunc"}, resolvedOptions) + args = append(args, "--format", "json") + + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "ListContainers", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return nil, errors.Join(runErr, normalizeCliErrors(errBuf)) + } + + rawContainers, decodeErr := decodeJSONLines[wslcListedContainer](outBuf) + listedContainers := make([]containers.ListedContainer, 0, len(rawContainers)) + labelInspectionIDs := make([]string, 0, len(rawContainers)) + for _, rawContainer := range rawContainers { + if rawContainer.ID == "" { + decodeErr = errors.Join( + decodeErr, + containers.ErrUnmarshalling, + fmt.Errorf("listed WSLC container did not contain an ID"), + ) + continue + } + + containerName := strings.TrimPrefix(strings.TrimSpace(strings.Split(rawContainer.Names, ",")[0]), "/") + listedContainers = append(listedContainers, containers.ListedContainer{ + Id: rawContainer.ID, + Name: containerName, + Image: rawContainer.Image, + Status: rawContainer.State, + Networks: splitCommaSeparated(rawContainer.Networks), + }) + labelInspectionIDs = append(labelInspectionIDs, rawContainer.ID) + } + + if len(labelInspectionIDs) > 0 { + inspectedContainers, inspectErr := wco.inspectContainersRaw(ctx, labelInspectionIDs) + labelsByID := make(map[string]map[string]string, len(inspectedContainers)) + for _, inspectedContainer := range inspectedContainers { + labelsByID[inspectedContainer.ID] = inspectedContainer.Config.Labels + } + for index := range listedContainers { + listedContainers[index].Labels = labelsByID[listedContainers[index].Id] + } + if inspectErr != nil || len(inspectedContainers) < len(labelInspectionIDs) { + decodeErr = errors.Join( + decodeErr, + fmt.Errorf("resolving authoritative labels for listed WSLC containers: %w", + errors.Join(inspectErr, incompleteError("containers", len(inspectedContainers), len(labelInspectionIDs)))), + ) + } + } + + return listedContainers, decodeErr +} + +func (wco *WslcCliOrchestrator) resolveListContainerNetworkFilters( + ctx context.Context, + options containers.ListContainersOptions, +) (containers.ListContainersOptions, error) { + if len(options.Filters.NetworkFilters) == 0 { + return options, nil + } + + resolvedFilters := make([]string, 0, len(options.Filters.NetworkFilters)) + for _, networkReference := range options.Filters.NetworkFilters { + network, resolveErr := wco.resolveNetwork(ctx, networkReference) + if resolveErr != nil { + return containers.ListContainersOptions{}, fmt.Errorf( + "resolving WSLC container-list network filter %q: %w", + networkReference, + resolveErr, + ) + } + resolvedFilters = append(resolvedFilters, network.Name) + } + + options.Filters.NetworkFilters = resolvedFilters + return options, nil +} + +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") + } + + rawContainers, inspectErr := wco.inspectContainersRaw(ctx, options.Containers) + + networkNamesSet := make(map[string]struct{}) + for _, rawContainer := range rawContainers { + for networkName := range rawContainer.NetworkSettings.Networks { + if networkName != "" { + networkNamesSet[networkName] = struct{}{} + } + } + } + + networkIDs := make(map[string]string, len(networkNamesSet)) + var networkResolutionErr error + if len(networkNamesSet) > 0 { + networkNames := make([]string, 0, len(networkNamesSet)) + for networkName := range networkNamesSet { + networkNames = append(networkNames, networkName) + } + sort.Strings(networkNames) + + inspectedNetworks, resolveErr := wco.InspectNetworks(ctx, containers.InspectNetworksOptions{ + Networks: networkNames, + }) + for _, inspectedNetwork := range inspectedNetworks { + networkIDs[inspectedNetwork.Name] = inspectedNetwork.Id + } + if resolveErr != nil { + networkResolutionErr = fmt.Errorf("resolving WSLC container network IDs: %w", resolveErr) + } + } + + inspectedContainers := make([]containers.InspectedContainer, 0, len(rawContainers)) + var conversionErr error + for _, rawContainer := range rawContainers { + inspectedContainer, convertErr := convertInspectedContainer(rawContainer, networkIDs) + if convertErr != nil { + conversionErr = errors.Join(conversionErr, containers.ErrUnmarshalling, convertErr) + continue + } + inspectedContainers = append(inspectedContainers, inspectedContainer) + } + + return inspectedContainers, errors.Join( + inspectErr, + networkResolutionErr, + conversionErr, + incompleteError("containers", len(inspectedContainers), len(options.Containers)), + ) +} + +func (wco *WslcCliOrchestrator) inspectContainersRaw( + ctx context.Context, + containerReferences []string, +) ([]wslcInspectedContainer, error) { + args := append([]string{"container", "inspect", "--format", "json"}, containerReferences...) + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "InspectContainers", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + + rawContainers, decodeErr := decodeJSONArray[wslcInspectedContainer](outBuf) + if runErr != nil { + runErr = errors.Join( + runErr, + normalizeCliErrors(errBuf, containerNotFoundMatch.MaxObjects(len(containerReferences))), + ) + } + return rawContainers, errors.Join(runErr, decodeErr) +} + +func convertInspectedContainer( + rawContainer wslcInspectedContainer, + networkIDs map[string]string, +) (containers.InspectedContainer, error) { + containerID := strings.TrimSpace(rawContainer.ID) + containerName := strings.TrimPrefix(strings.TrimSpace(rawContainer.Name), "/") + if containerID == "" { + return containers.InspectedContainer{}, fmt.Errorf("inspected WSLC container did not contain an ID") + } + if containerName == "" { + return containers.InspectedContainer{}, fmt.Errorf("inspected WSLC container %q did not contain a name", containerID) + } + + status := rawContainer.State.Status + if status == "" && rawContainer.State.Running { + status = containers.ContainerStatusRunning + } + + inspectedContainer := containers.InspectedContainer{ + Id: containerID, + Name: containerName, + Image: rawContainer.Config.Image, + CreatedAt: rawContainer.Created.Time, + StartedAt: rawContainer.State.StartedAt.Time, + FinishedAt: rawContainer.State.FinishedAt.Time, + Status: status, + Error: rawContainer.State.Error, + ExitCode: rawContainer.State.ExitCode, + Healthcheck: rawContainer.Config.Healthcheck.Test, + Health: rawContainer.State.Health, + Labels: rawContainer.Config.Labels, + } + + inspectedContainer.Env = make(map[string]string, len(rawContainer.Config.Env)) + for _, envValue := range rawContainer.Config.Env { + name, value, found := strings.Cut(envValue, "=") + if found { + inspectedContainer.Env[name] = value + } else { + inspectedContainer.Env[envValue] = "" + } + } + + inspectedContainer.Args = append(inspectedContainer.Args, rawContainer.Config.Entrypoint...) + inspectedContainer.Args = append(inspectedContainer.Args, rawContainer.Config.Cmd...) + + for _, rawMount := range rawContainer.Mounts { + source := rawMount.Source + if rawMount.Type == containers.NamedVolumeMount { + source = rawMount.Name + } + inspectedContainer.Mounts = append(inspectedContainer.Mounts, containers.VolumeMount{ + Type: rawMount.Type, + Source: source, + Target: rawMount.Destination, + ReadOnly: !rawMount.ReadWrite, + }) + } + + if rawContainer.Ports != nil { + inspectedContainer.Ports = make(containers.InspectedContainerPortMapping) + for portAndProtocol, bindings := range rawContainer.Ports { + if portAndProtocol == "" || len(bindings) == 0 { + continue + } + inspectedContainer.Ports[portAndProtocol] = bindings + } + } + + networkNames := make([]string, 0, len(rawContainer.NetworkSettings.Networks)) + for networkName := range rawContainer.NetworkSettings.Networks { + networkNames = append(networkNames, networkName) + } + sort.Strings(networkNames) + for _, networkName := range networkNames { + rawNetwork := rawContainer.NetworkSettings.Networks[networkName] + inspectedContainer.Networks = append(inspectedContainer.Networks, containers.InspectedContainerNetwork{ + Id: networkIDs[networkName], + Name: networkName, + IPAddress: rawNetwork.IPAddress, + MacAddress: rawNetwork.MacAddress, + Gateway: rawNetwork.Gateway, + Aliases: rawNetwork.Aliases, + }) + } + + return inspectedContainer, nil +} + +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") + } + defer closeWriteCloser(options.StdOutStream) + defer closeWriteCloser(options.StdErrStream) + + return runSequentially(ctx, "containers", options.Containers, func(containerReference string) error { + cmd := makeWslcCommand("container", "start", containerReference) + _, errBuf, runErr := wco.runBufferedWslcCommandInternal( + ctx, + "StartContainer", + cmd, + options.StdOutStream, + options.StdErrStream, + ordinaryCommandTimeout, + false, + ) + if runErr != nil { + return errors.Join(runErr, normalizeCliErrors(errBuf, containerNotFoundMatch)) + } + return nil + }) +} + +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") + } + + timeout := ordinaryCommandTimeout + stopArgs := []string{"container", "stop"} + if options.SecondsToKill > 0 { + stopArgs = append(stopArgs, "-t", fmt.Sprintf("%d", options.SecondsToKill)) + gracePeriod := time.Duration(options.SecondsToKill) * time.Second + if gracePeriod < 0 { + return nil, fmt.Errorf("container stop timeout is too large") + } + timeout += gracePeriod + } + + return runSequentially(ctx, "containers", options.Containers, func(containerReference string) error { + args := append(append([]string{}, stopArgs...), containerReference) + cmd := makeWslcCommand(args...) + _, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "StopContainer", + cmd, + nil, + nil, + timeout, + ) + if runErr != nil { + return errors.Join(runErr, normalizeCliErrors(errBuf, containerNotFoundMatch)) + } + return nil + }) +} + +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") + } + + return runSequentially(ctx, "containers", options.Containers, func(containerReference string) error { + args := []string{"container", "remove", "--volumes"} + if options.Force { + args = append(args, "--force") + } + args = append(args, containerReference) + + cmd := makeWslcCommand(args...) + _, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "RemoveContainer", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return errors.Join(runErr, normalizeCliErrors(errBuf, containerNotFoundMatch, objectInUseMatch)) + } + return nil + }) +} + +func runSequentially( + ctx context.Context, + objectKind string, + references []string, + run func(reference string) error, +) ([]string, error) { + successes := make([]string, 0, len(references)) + var operationErrors error + + for _, reference := range references { + if contextErr := ctx.Err(); contextErr != nil { + operationErrors = errors.Join(operationErrors, contextErr) + break + } + if reference == "" { + operationErrors = errors.Join(operationErrors, fmt.Errorf("%s reference cannot be empty", objectKind)) + continue + } + + if runErr := run(reference); runErr != nil { + operationErrors = errors.Join(operationErrors, fmt.Errorf("processing %s %q: %w", objectKind, reference, runErr)) + continue + } + successes = append(successes, reference) + } + + return successes, errors.Join( + operationErrors, + incompleteError(objectKind, len(successes), len(references)), + ) +} + +func (wco *WslcCliOrchestrator) ExecContainer(ctx context.Context, options containers.ExecContainerOptions) (<-chan int32, error) { + if contextErr := ctx.Err(); contextErr != nil { + return nil, contextErr + } + if options.Container == "" { + return nil, fmt.Errorf("must specify a container") + } + if options.Command == "" { + return nil, fmt.Errorf("must specify a command") + } + + args := []string{"container", "exec"} + if options.WorkingDirectory != "" { + args = append(args, "--workdir", options.WorkingDirectory) + } + for _, envVar := range options.Env { + if envVar.Name == "" { + return nil, fmt.Errorf("container exec environment variable name cannot be empty") + } + args = append(args, "--env", envVar.Name+"="+envVar.Value) + } + for _, envFile := range options.EnvFiles { + if envFile == "" { + return nil, fmt.Errorf("container exec environment file path cannot be empty") + } + args = append(args, "--env-file", envFile) + } + args = append(args, options.Container, options.Command) + args = append(args, options.Args...) + + cmd := makeWslcCommand(args...) + cmd.Stdout = options.StdOutStream + cmd.Stderr = options.StdErrStream + + exitCodes := make(chan int32, 1) + exitHandler := process.ProcessExitHandlerFunc(func(_ process.Pid_t, exitCode int32, exitErr error) { + if exitErr != nil && !errors.Is(exitErr, context.Canceled) && !errors.Is(exitErr, context.DeadlineExceeded) { + wco.log.Error(exitErr, "WSLC container exec command failed", "Container", options.Container) + } + exitCodes <- exitCode + close(exitCodes) + }) + + wco.log.V(1).Info("Running WSLC command", "Command", cmd.String()) + _, startWaitForExit, startErr := wco.executor.StartProcess( + ctx, + cmd, + exitHandler, + process.CreationFlagEnsureKillOnDispose, + nil, + ) + if startErr != nil { + return nil, fmt.Errorf("failed to start WSLC container exec command: %w", startErr) + } + startWaitForExit() + return exitCodes, nil +} + +func (wco *WslcCliOrchestrator) AttachContainer( + ctx context.Context, + options containers.AttachContainerOptions, +) (*termpty.PseudoTerminalProcess, error) { + if contextErr := ctx.Err(); contextErr != nil { + return nil, contextErr + } + if options.Container == "" { + return nil, fmt.Errorf("must specify a container") + } + + 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) CreateFiles(ctx context.Context, options containers.CreateFilesOptions) error { + if options.Container == "" { + return fmt.Errorf("must specify a container") + } + if len(options.Entries) == 0 { + return fmt.Errorf("must specify at least one file-system entry") + } + + archive, archiveErr := containers.CreateFilesArchive(ctx, wco.log, options) + if archiveErr != nil { + return archiveErr + } + if archive == nil { + return nil + } + + cmd := makeWslcCommand( + "container", + "cp", + "-a=false", + "-", + options.Container+":/", + ) + cmd.Stdin = archive + _, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "CreateFiles", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return errors.Join(runErr, normalizeCliErrors(errBuf, containerNotFoundMatch)) + } + return nil +} + +func (wco *WslcCliOrchestrator) CaptureContainerLogs( + ctx context.Context, + containerReference string, + stdout usvc_io.WriteSyncerCloser, + stderr usvc_io.WriteSyncerCloser, + options containers.StreamContainerLogsOptions, +) error { + if containerReference == "" { + return fmt.Errorf("must specify a container") + } + if stdout == nil || stderr == nil { + return fmt.Errorf("container log destinations cannot be nil") + } + + args := options.Apply([]string{"container", "logs"}) + args = append(args, containerReference) + cmd := makeWslcCommand(args...) + + exitHandler, startErr := wco.startStreamingWslcCommand( + ctx, + "CaptureContainerLogs", + cmd, + stdout, + stderr, + ) + if startErr != nil { + closeLogDestination(wco, containerReference, "stdout", stdout) + closeLogDestination(wco, containerReference, "stderr", stderr) + return startErr + } + + go func() { + <-exitHandler.Exited() + exitInfo := exitHandler.ExitInfo() + if exitInfo.Err != nil && + !errors.Is(exitInfo.Err, context.Canceled) && + !errors.Is(exitInfo.Err, context.DeadlineExceeded) { + wco.log.Error(exitInfo.Err, "Capturing WSLC container logs failed", "Container", containerReference) + } else if exitInfo.ExitCode != 0 { + wco.log.Error( + fmt.Errorf("wslc logs command exited with code %d", exitInfo.ExitCode), + "Capturing WSLC container logs failed", + "Container", + containerReference, + ) + } + + closeLogDestination(wco, containerReference, "stdout", stdout) + closeLogDestination(wco, containerReference, "stderr", stderr) + }() + + return nil +} + +func closeLogDestination( + wco *WslcCliOrchestrator, + containerReference string, + streamName string, + destination usvc_io.WriteSyncerCloser, +) { + if closeErr := destination.Close(); closeErr != nil { + wco.log.Error(closeErr, "Closing container log destination failed", "Container", containerReference, "Stream", streamName) + } +} diff --git a/internal/wslc/containers_test.go b/internal/wslc/containers_test.go new file mode 100644 index 00000000..77285d7e --- /dev/null +++ b/internal/wslc/containers_test.go @@ -0,0 +1,729 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "errors" + "io" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/microsoft/dcp/internal/containers" + internal_testutil "github.com/microsoft/dcp/internal/testutil" + usvc_io "github.com/microsoft/dcp/pkg/io" +) + +func TestApplyCreateContainerOptionsUsesNativeWslcSyntax(t *testing.T) { + t.Parallel() + + args, applyErr := applyCreateContainerOptions([]string{"container", "create"}, containers.CreateContainerOptions{ + Name: "test-container", + Image: "example.test/image:latest", + Entrypoint: "/entrypoint", + Command: []string{"arg"}, + Env: []containers.EnvVar{{Name: "ONE", Value: "two"}}, + EnvFiles: []string{`C:\config path\env.list`}, + Ports: []containers.CreateContainerPort{{ + ContainerPort: 8080, + Protocol: "tcp", + }}, + VolumeMounts: []containers.CreateContainerVolumeMount{{ + Type: containers.BindMount, + Source: `C:\host path\data`, + Target: "/data", + ReadOnly: true, + }}, + Labels: []containers.Label{{Key: "owner", Value: "dcp"}}, + PullPolicy: containers.PullPolicyMissing, + Networks: []containers.CreateContainerNetworkOptions{ + {Name: "first", Aliases: []string{"one", "two"}}, + {Name: "second", Aliases: []string{"three"}}, + }, + Healthcheck: containers.ContainerHealthcheck{ + Command: []string{"CMD-SHELL", "test -f /ready"}, + Timeout: 2 * time.Second, + }, + AttachTerminal: true, + RunArgs: []string{"--custom-option"}, + }) + + require.NoError(t, applyErr) + require.Equal(t, []string{ + "container", "create", + "--name", "test-container", + "--network", "name=first,alias=one,alias=two", + "--network", "name=second,alias=three", + "--mount", `type=bind,src=C:\host path\data,target=/data,readonly`, + "--publish", "127.0.0.1::8080/tcp", + "--env", "ONE=two", + "--env-file", `C:\config path\env.list`, + "--label", "owner=dcp", + "--pull", "missing", + "--entrypoint", "/entrypoint", + "--health-cmd", "CMD-SHELL test -f /ready", + "--health-interval", "30s", + "--health-timeout", "2s", + "--health-retries", "3", + "--interactive", "--tty", + "--custom-option", + }, args) +} + +func TestApplyCreateContainerOptionsRejectsUnsupportedSettings(t *testing.T) { + t.Parallel() + + _, restartErr := applyCreateContainerOptions(nil, containers.CreateContainerOptions{ + Image: "image", + RestartPolicy: containers.RestartPolicyAlways, + }) + require.ErrorContains(t, restartErr, "restart policy") + + _, startIntervalErr := applyCreateContainerOptions(nil, containers.CreateContainerOptions{ + Image: "image", + Healthcheck: containers.ContainerHealthcheck{ + StartInterval: time.Second, + }, + }) + require.ErrorContains(t, startIntervalErr, "start intervals") + + _, commandErr := applyCreateContainerOptions(nil, containers.CreateContainerOptions{ + Image: "image", + Healthcheck: containers.ContainerHealthcheck{ + Timeout: time.Second, + }, + }) + require.ErrorContains(t, commandErr, "require a health-check command") +} + +func TestApplyCreateContainerOptionsDeduplicatesLabelsLastValueWins(t *testing.T) { + t.Parallel() + + args, applyErr := applyCreateContainerOptions( + []string{"container", "create"}, + containers.CreateContainerOptions{ + Image: "busybox:latest", + Entrypoint: "sh", + AttachTerminal: true, + Labels: []containers.Label{ + {Key: "persistent", Value: "tracker"}, + {Key: "owner", Value: "dcp"}, + {Key: "creator", Value: "first"}, + {Key: "persistent", Value: "controller"}, + {Key: "creator", Value: "last"}, + }, + }, + ) + + require.NoError(t, applyErr) + require.Equal(t, []string{ + "container", "create", + "--label", "creator=last", + "--label", "owner=dcp", + "--label", "persistent=controller", + "--entrypoint", "sh", + "--interactive", "--tty", + }, args) +} + +func TestCreateContainerResolvesInitialNetworkIDWithoutAliases(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "full-network-id"}, + `[{"Id":"full-network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "create", "--network", "network-name", "image", "command"}, + "container-id\n", + "", + 0, + ) + requestedNetworks := []containers.CreateContainerNetworkOptions{{ + Name: "full-network-id", + }} + + containerID, createErr := orchestrator.CreateContainer(ctx, containers.CreateContainerOptions{ + Image: "image", + Command: []string{"command"}, + Networks: requestedNetworks, + }) + + require.NoError(t, createErr) + require.Equal(t, "container-id", containerID) + require.Equal(t, []containers.CreateContainerNetworkOptions{{Name: "full-network-id"}}, requestedNetworks) +} + +func TestRunContainerResolvesMultipleInitialNetworkIDsAndPreservesAliases(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "first-network-id"}, + `[{"Id":"first-network-id","Name":"first-network","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "second-network-id"}, + `[{"Id":"second-network-id","Name":"second-network","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{ + "wslc", "container", "run", + "--network", "name=first-network,alias=first-alias", + "--network", "name=second-network,alias=second-alias,alias=extra-alias", + "--detach", + "image", "command", + }, + "container-id\n", + "", + 0, + ) + requestedNetworks := []containers.CreateContainerNetworkOptions{ + {Name: "first-network-id", Aliases: []string{"first-alias"}}, + {Name: "second-network-id", Aliases: []string{"second-alias", "extra-alias"}}, + } + expectedRequestedNetworks := []containers.CreateContainerNetworkOptions{ + {Name: "first-network-id", Aliases: []string{"first-alias"}}, + {Name: "second-network-id", Aliases: []string{"second-alias", "extra-alias"}}, + } + + containerID, runErr := orchestrator.RunContainer(ctx, containers.RunContainerOptions{ + CreateContainerOptions: containers.CreateContainerOptions{ + Image: "image", + Command: []string{"command"}, + Networks: requestedNetworks, + }, + }) + + require.NoError(t, runErr) + require.Equal(t, "container-id", containerID) + require.Equal(t, expectedRequestedNetworks, requestedNetworks) +} + +func TestContainerCreationReturnsInitialNetworkResolutionErrors(t *testing.T) { + t.Parallel() + + for _, operation := range []string{"create", "run"} { + t.Run(operation, func(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "missing-network-id"}, + `[]`, + "Network not found: 'missing-network-id'\n", + 1, + ) + options := containers.CreateContainerOptions{ + Image: "image", + Networks: []containers.CreateContainerNetworkOptions{{ + Name: "missing-network-id", + Aliases: []string{"alias"}, + }}, + } + + var creationErr error + if operation == "create" { + _, creationErr = orchestrator.CreateContainer(ctx, options) + } else { + _, creationErr = orchestrator.RunContainer(ctx, containers.RunContainerOptions{ + CreateContainerOptions: options, + }) + } + + require.ErrorIs(t, creationErr, containers.ErrNotFound) + require.ErrorContains(t, creationErr, `resolving initial container network "missing-network-id"`) + require.Empty(t, executor.FindAll([]string{"wslc", "container", operation}, "", nil)) + }) + } +} + +func TestInspectContainersMapsWslcLayoutAndResolvesNetworkIDs(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container-name"}, + `[{ + "Id":"container-id", + "Name":"/container-name", + "Created":"2026-01-02T03:04:05Z", + "Config":{ + "Image":"example.test/image:tag", + "Cmd":["arg1","arg2"], + "Entrypoint":["/entrypoint"], + "Env":["ONE=two","EMPTY","WITH_EQUALS=a=b"], + "Labels":{"owner":"dcp"}, + "Healthcheck":{"Test":["CMD-SHELL","test -f /ready"]} + }, + "State":{ + "Status":"running", + "Running":true, + "StartedAt":"2026-01-02T03:05:05Z", + "FinishedAt":"0001-01-01T00:00:00Z", + "ExitCode":0, + "Error":"", + "Health":{"Status":"healthy","FailingStreak":0,"Log":[]} + }, + "Ports":{"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"49152"}]}, + "Mounts":[ + {"Type":"bind","Source":"C:\\host path\\data","Destination":"/bind","ReadWrite":false}, + {"Type":"volume","Source":"/ignored","Name":"named-volume","Destination":"/volume","ReadWrite":true} + ], + "NetworkSettings":{"Networks":{ + "bridge":{"Aliases":["container-name"],"Gateway":"172.20.0.1","IPAddress":"172.20.0.2","MacAddress":"00:11:22:33:44:55"}, + "custom":{"Aliases":["alias"],"Gateway":"172.21.0.1","IPAddress":"172.21.0.2","MacAddress":"00:11:22:33:44:66"} + }} + }]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "bridge", "custom"}, + `[ + {"Id":"bridge-id","Name":"bridge","Driver":"bridge","IPAM":{"Config":[]},"Containers":{}}, + {"Id":"custom-id","Name":"custom","Driver":"bridge","IPAM":{"Config":[]},"Containers":{}} + ]`, + "", + 0, + ) + + inspected, inspectErr := orchestrator.InspectContainers(ctx, containers.InspectContainersOptions{ + Containers: []string{"container-name"}, + }) + + require.NoError(t, inspectErr) + require.Len(t, inspected, 1) + container := inspected[0] + require.Equal(t, "container-id", container.Id) + require.Equal(t, "container-name", container.Name) + require.Equal(t, "example.test/image:tag", container.Image) + require.Equal(t, containers.ContainerStatusRunning, container.Status) + require.Equal(t, []string{"/entrypoint", "arg1", "arg2"}, container.Args) + require.Equal(t, "two", container.Env["ONE"]) + require.Equal(t, "", container.Env["EMPTY"]) + require.Equal(t, "a=b", container.Env["WITH_EQUALS"]) + require.Equal(t, []string{"CMD-SHELL", "test -f /ready"}, container.Healthcheck) + require.NotNil(t, container.Health) + require.Equal(t, "healthy", container.Health.Status) + require.Equal(t, "49152", container.Ports["8080/tcp"][0].HostPort) + require.Equal(t, containers.VolumeMount{ + Type: containers.BindMount, + Source: `C:\host path\data`, + Target: "/bind", + ReadOnly: true, + }, container.Mounts[0]) + require.Equal(t, "named-volume", container.Mounts[1].Source) + require.Equal(t, "bridge-id", container.Networks[0].Id) + require.Equal(t, "custom-id", container.Networks[1].Id) + require.Equal(t, "alias", container.Networks[1].Aliases[0]) + require.Equal(t, "dcp", container.Labels["owner"]) +} + +func TestInspectContainersDoesNotInventMissingNetworkIDs(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container-name"}, + `[{"Id":"container-id","Name":"/container-name","Config":{"Image":"image"},"State":{"Status":"created"},"NetworkSettings":{"Networks":{"gone":{}}}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "gone"}, + `[]`, + "Network not found: 'gone'\n", + 1, + ) + + inspected, inspectErr := orchestrator.InspectContainers(ctx, containers.InspectContainersOptions{ + Containers: []string{"container-name"}, + }) + + require.ErrorIs(t, inspectErr, containers.ErrNotFound) + require.Len(t, inspected, 1) + require.Len(t, inspected[0].Networks, 1) + require.Equal(t, "gone", inspected[0].Networks[0].Name) + require.Empty(t, inspected[0].Networks[0].Id) +} + +func TestInspectContainersPreservesValidObjectAlongsideMissingReference(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "present", "missing"}, + `[{"Id":"container-id","Name":"/present","Config":{"Image":"image"},"State":{"Status":"running"},"NetworkSettings":{"Networks":{"bridge":{}}}}]`, + "Container 'missing' not found.\n", + 1, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "bridge"}, + `[{"Id":"bridge-id","Name":"bridge","IPAM":{"Config":[]},"Containers":{"container-id":{"Name":"present"}}}]`, + "", + 0, + ) + + inspected, inspectErr := orchestrator.InspectContainers(ctx, containers.InspectContainersOptions{ + Containers: []string{"present", "missing"}, + }) + + require.Len(t, inspected, 1) + require.Equal(t, "container-id", inspected[0].Id) + require.Equal(t, "bridge-id", inspected[0].Networks[0].Id) + require.ErrorIs(t, inspectErr, containers.ErrNotFound) + require.ErrorIs(t, inspectErr, containers.ErrIncomplete) +} + +func TestListContainersUsesInspectionForLabelsWithCommas(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "list", "--no-trunc", "--all", "--filter", "label=owner=dcp", "--format", "json"}, + `{"ID":"container-id","Names":"container-name","Image":"image","State":"running","Networks":"bridge, custom","Labels":"owner=dcp,com.microsoft.wslc.metadata={\"one\":1,\"two\":2}"}`+"\n", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container-id"}, + `[{"Id":"container-id","Name":"/container-name","Config":{"Labels":{"owner":"dcp","value":"one,two=three"}}}]`, + "", + 0, + ) + + listed, listErr := orchestrator.ListContainers(ctx, containers.ListContainersOptions{ + All: true, + Filters: containers.ListContainersFilters{ + LabelFilters: []containers.LabelFilter{{Key: "owner", Value: "dcp"}}, + }, + }) + + require.NoError(t, listErr) + require.Len(t, listed, 1) + require.Equal(t, "one,two=three", listed[0].Labels["value"]) + require.Equal(t, []string{"bridge", "custom"}, listed[0].Networks) +} + +func TestListContainersResolvesNetworkIDFiltersToNativeNames(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "list", "--no-trunc", "--all", "--filter", "network=network-name", "--format", "json"}, + "", + "", + 0, + ) + + listed, listErr := orchestrator.ListContainers(ctx, containers.ListContainersOptions{ + All: true, + Filters: containers.ListContainersFilters{ + NetworkFilters: []string{"network-id"}, + }, + }) + + require.NoError(t, listErr) + require.Empty(t, listed) + require.Empty(t, executor.FindAll( + []string{"wslc", "container", "list", "--no-trunc", "--all", "--filter", "network=network-id"}, + "", + nil, + )) +} + +func TestCreateContainerReturnsPartialIDOnCommandFailure(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "create", "--name", "container-name", "image"}, + "container-id\n", + "unexpected post-create failure\n", + 1, + ) + + containerID, createErr := orchestrator.CreateContainer(ctx, containers.CreateContainerOptions{ + Name: "container-name", + Image: "image", + }) + + require.Equal(t, "container-id", containerID) + require.Error(t, createErr) +} + +func TestRunContainerUsesDetachAndReturnsID(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{ + "wslc", "container", "run", + "--name", "container-name", + "--detach", + "--custom-option", + "image", + "command", "arg", + }, + "container-id\n", + "", + 0, + ) + + containerID, runErr := orchestrator.RunContainer(ctx, containers.RunContainerOptions{ + CreateContainerOptions: containers.CreateContainerOptions{ + Name: "container-name", + Image: "image", + Command: []string{"command", "arg"}, + RunArgs: []string{"--custom-option"}, + }, + }) + + require.NoError(t, runErr) + require.Equal(t, "container-id", containerID) +} + +func TestStartContainersRunsOneNativeCommandPerContainer(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand(t, executor, []string{"wslc", "container", "start", "first"}, "first\n", "", 0) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "start", "missing"}, + "", + "Container 'missing' not found.\n", + 1, + ) + installAutoCommand(t, executor, []string{"wslc", "container", "start", "third"}, "third\n", "", 0) + + started, startErr := orchestrator.StartContainers(ctx, containers.StartContainersOptions{ + Containers: []string{"first", "missing", "third"}, + }) + + require.Equal(t, []string{"first", "third"}, started) + require.ErrorIs(t, startErr, containers.ErrNotFound) + require.ErrorIs(t, startErr, containers.ErrIncomplete) + require.Len(t, executor.FindAll([]string{"wslc", "container", "start"}, "", nil), 3) +} + +func TestStopContainersUsesNativeTimeoutFlag(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "stop", "-t", "5", "container"}, + "container\n", + "", + 0, + ) + + stopped, stopErr := orchestrator.StopContainers(ctx, containers.StopContainersOptions{ + Containers: []string{"container"}, + SecondsToKill: 5, + }) + + require.NoError(t, stopErr) + require.Equal(t, []string{"container"}, stopped) +} + +func TestExecContainerKeepsStreamsSeparateAndBuffersExitCode(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "exec", "--workdir", "/work", "--env", "ONE=two", "container", "command", "arg"}, + "stdout-value", + "stderr-value", + 7, + ) + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCodes, execErr := orchestrator.ExecContainer(ctx, containers.ExecContainerOptions{ + Container: "container", + WorkingDirectory: "/work", + Env: []containers.EnvVar{{Name: "ONE", Value: "two"}}, + Command: "command", + Args: []string{"arg"}, + StreamCommandOptions: containers.StreamCommandOptions{ + StdOutStream: usvc_io.NopWriteCloser(&stdout), + StdErrStream: usvc_io.NopWriteCloser(&stderr), + }, + }) + + require.NoError(t, execErr) + require.Equal(t, 1, cap(exitCodes)) + require.Equal(t, int32(7), <-exitCodes) + _, open := <-exitCodes + require.False(t, open) + require.Equal(t, "stdout-value", stdout.String()) + require.Equal(t, "stderr-value", stderr.String()) +} + +func TestCreateFilesCopiesGeneratedArchiveOnStdin(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + var archiveSize int + executor.InstallAutoExecution(internal_testutil.AutoExecution{ + Condition: internal_testutil.ProcessSearchCriteria{ + Command: []string{"wslc", "container", "cp", "-a=false", "-", "container:/"}, + }, + RunCommand: func(execution *internal_testutil.ProcessExecution) int32 { + archive, readErr := io.ReadAll(execution.Cmd.Stdin) + require.NoError(t, readErr) + archiveSize = len(archive) + return 0 + }, + }) + + createErr := orchestrator.CreateFiles(ctx, containers.CreateFilesOptions{ + Container: "container", + Destination: "/data", + ModTime: time.Unix(1, 0), + Entries: []containers.FileSystemEntry{{ + Name: "file.txt", + Contents: "contents", + }}, + }) + + require.NoError(t, createErr) + require.Positive(t, archiveSize) +} + +func TestCreateFilesRejectsEmptyEntries(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + createErr := orchestrator.CreateFiles(ctx, containers.CreateFilesOptions{ + Container: "container", + }) + + require.ErrorContains(t, createErr, "at least one file-system entry") + require.Empty(t, executor.Executions) +} + +func TestCaptureContainerLogsSeparatesAndClosesStreams(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "logs", "--follow", "--timestamps", "container"}, + "stdout-log", + "stderr-log", + 0, + ) + stdout := newTestWriteSyncCloser() + stderr := newTestWriteSyncCloser() + + captureErr := orchestrator.CaptureContainerLogs( + ctx, + "container", + stdout, + stderr, + containers.StreamContainerLogsOptions{Follow: true, Timestamps: true}, + ) + require.NoError(t, captureErr) + + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-stdout.closed: + } + select { + case <-ctx.Done(): + t.Fatal(ctx.Err()) + case <-stderr.closed: + } + require.Equal(t, "stdout-log", stdout.String()) + require.Equal(t, "stderr-log", stderr.String()) + require.Equal(t, int32(0), stdout.syncCount.Load()) + require.Equal(t, int32(0), stderr.syncCount.Load()) +} + +func TestSequentialOperationsPreservePartialResults(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand(t, executor, []string{"wslc", "container", "remove", "--volumes", "--force", "first"}, "", "", 0) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "remove", "--volumes", "--force", "missing"}, + "", + "Container 'missing' not found.\n", + 1, + ) + + removed, removeErr := orchestrator.RemoveContainers(ctx, containers.RemoveContainersOptions{ + Containers: []string{"first", "missing"}, + Force: true, + }) + + require.Equal(t, []string{"first"}, removed) + require.True(t, errors.Is(removeErr, containers.ErrNotFound)) + require.True(t, errors.Is(removeErr, containers.ErrIncomplete)) +} diff --git a/internal/wslc/events.go b/internal/wslc/events.go new file mode 100644 index 00000000..4efde11c --- /dev/null +++ b/internal/wslc/events.go @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "fmt" + + "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/internal/pubsub" +) + +func (*WslcCliOrchestrator) WatchContainers( + _ chan<- containers.EventMessage, +) (*pubsub.Subscription[containers.EventMessage], error) { + return nil, fmt.Errorf("wslc container events are unsupported because the WSLC CLI does not expose a native event stream") +} + +func (*WslcCliOrchestrator) WatchNetworks( + _ chan<- containers.EventMessage, +) (*pubsub.Subscription[containers.EventMessage], error) { + return nil, fmt.Errorf("wslc network events are unsupported because the WSLC CLI does not expose a native event stream") +} diff --git a/internal/wslc/images.go b/internal/wslc/images.go new file mode 100644 index 00000000..03bc0bf7 --- /dev/null +++ b/internal/wslc/images.go @@ -0,0 +1,258 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "context" + "errors" + "fmt" + "os" + "sort" + "strings" + + "github.com/microsoft/dcp/internal/containers" +) + +func (wco *WslcCliOrchestrator) BuildImage(ctx context.Context, options containers.BuildImageOptions) error { + if options.ContainerBuildContext == nil { + return fmt.Errorf("must specify a container build context") + } + if options.Context == "" { + return fmt.Errorf("container build context path cannot be empty") + } + if options.Context == "-" { + return fmt.Errorf("wslc requires a directory build context; stdin build contexts are unsupported") + } + if options.Platform != "" { + return fmt.Errorf("wslc does not support selecting build platform %q", options.Platform) + } + + args := []string{"image", "build"} + if options.Dockerfile != "" { + args = append(args, "--file", options.Dockerfile) + } + if options.Pull { + args = append(args, "--pull") + } + if options.IidFile != "" { + args = append(args, "--iidfile", options.IidFile) + } + for _, tag := range options.Tags { + if tag == "" { + return fmt.Errorf("image build tag cannot be empty") + } + args = append(args, "--tag", tag) + } + for _, buildArg := range options.Args { + if buildArg.Name == "" { + return fmt.Errorf("image build argument name cannot be empty") + } + if buildArg.Value == "" { + args = append(args, "--build-arg", buildArg.Name) + } else { + args = append(args, "--build-arg", buildArg.Name+"="+buildArg.Value) + } + } + + secretEnvironment := make(map[string]string) + for _, secret := range options.Secrets { + if secret.ID == "" { + return fmt.Errorf("image build secret ID cannot be empty") + } + + switch secret.Type { + case "", containers.FileSecret: + if secret.Source == "" { + return fmt.Errorf("file build secret %q must specify a source path", secret.ID) + } + args = append(args, "--secret", fmt.Sprintf("id=%s,type=file,src=%s", secret.ID, secret.Source)) + case containers.EnvSecret: + environmentName := secret.Source + if environmentName == "" { + environmentName = secret.ID + } + args = append(args, "--secret", fmt.Sprintf("id=%s,type=env,env=%s", secret.ID, environmentName)) + if secret.Value != "" { + secretEnvironment[environmentName] = secret.Value + } + default: + return fmt.Errorf("unsupported image build secret type %q", secret.Type) + } + } + + if options.Stage != "" { + args = append(args, "--target", options.Stage) + } + var labelArgsErr error + args, labelArgsErr = appendLabelArgs(args, options.Labels, "image") + if labelArgsErr != nil { + return labelArgsErr + } + + args = append(args, "--progress", "plain", options.Context) + cmd := makeWslcCommand(args...) + if len(secretEnvironment) > 0 { + cmd.Env = os.Environ() + secretNames := make([]string, 0, len(secretEnvironment)) + for secretName := range secretEnvironment { + secretNames = append(secretNames, secretName) + } + sort.Strings(secretNames) + for _, secretName := range secretNames { + cmd.Env = append(cmd.Env, secretName+"="+secretEnvironment[secretName]) + } + } + + timeout := options.Timeout + if timeout == 0 { + timeout = defaultBuildTimeout + } + _, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "BuildImage", + cmd, + options.StdOutStream, + options.StdErrStream, + timeout, + ) + if runErr != nil { + return errors.Join(runErr, normalizeCliErrors(errBuf, imageNotFoundMatch)) + } + + if options.IidFile != "" { + if _, iidErr := containers.ReadImageIDFile(options.IidFile); iidErr != nil { + return fmt.Errorf("validating WSLC image ID file %q: %w", options.IidFile, iidErr) + } + } + return nil +} + +func isImageIdentifier(value string) bool { + return strings.HasPrefix(value, "sha256:") && len(value) > len("sha256:") +} + +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") + } + + args := append([]string{"image", "inspect", "--format", "json"}, options.Images...) + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "InspectImages", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + + rawImages, decodeErr := decodeJSONArray[wslcInspectedImage](outBuf) + if runErr != nil { + runErr = errors.Join(runErr, normalizeCliErrors(errBuf, imageNotFoundMatch.MaxObjects(len(options.Images)))) + } + + inspectedImages := make([]containers.InspectedImage, 0, len(rawImages)) + var conversionErr error + for _, rawImage := range rawImages { + imageID := strings.TrimSpace(rawImage.ID) + if imageID == "" { + conversionErr = errors.Join( + conversionErr, + containers.ErrUnmarshalling, + fmt.Errorf("inspected WSLC image did not contain an ID"), + ) + continue + } + inspectedImages = append(inspectedImages, containers.InspectedImage{ + Id: imageID, + Labels: rawImage.Config.Labels, + Tags: rawImage.RepoTags, + Digest: imageDigest(rawImage.RepoDigests), + }) + } + + return inspectedImages, errors.Join( + runErr, + decodeErr, + conversionErr, + incompleteError("images", len(inspectedImages), len(options.Images)), + ) +} + +func (wco *WslcCliOrchestrator) PullImage(ctx context.Context, options containers.PullImageOptions) (string, error) { + if options.Image == "" { + return "", fmt.Errorf("must specify an image to pull") + } + + imageReference := options.Image + if options.Digest != "" { + imageReference += "@" + options.Digest + } + cmd := makeWslcCommand("image", "pull", "--quiet", imageReference) + + timeout := options.Timeout + if timeout == 0 { + timeout = defaultPullTimeout + } + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "PullImage", + cmd, + nil, + nil, + timeout, + ) + if runErr != nil { + return "", errors.Join(runErr, normalizeCliErrors(errBuf, imageNotFoundMatch)) + } + + if imageID, idErr := parseSingleIdentifier(outBuf); idErr == nil && isImageIdentifier(imageID) { + return imageID, nil + } + + inspectedImages, inspectErr := wco.InspectImages(ctx, containers.InspectImagesOptions{ + Images: []string{imageReference}, + }) + if inspectErr != nil { + return "", fmt.Errorf("resolving pulled WSLC image ID: %w", inspectErr) + } + if len(inspectedImages) != 1 || inspectedImages[0].Id == "" { + return "", fmt.Errorf("pulled WSLC image %q did not report an image ID", imageReference) + } + return inspectedImages[0].Id, nil +} + +func (wco *WslcCliOrchestrator) RemoveImages(ctx context.Context, options containers.RemoveImagesOptions) ([]string, error) { + return containers.RemoveImagesSequentially(ctx, options, func(removeCtx context.Context, image string, force bool) error { + args := []string{"image", "remove"} + if force { + args = append(args, "--force") + } + args = append(args, image) + + cmd := makeWslcCommand(args...) + _, errBuf, runErr := wco.runBufferedWslcCommand( + removeCtx, + "RemoveImage", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return errors.Join(runErr, normalizeCliErrors(errBuf, imageNotFoundMatch, objectInUseMatch)) + } + return nil + }) +} + +func (wco *WslcCliOrchestrator) ApplyImageLayers( + ctx context.Context, + options containers.ApplyImageLayersOptions, +) (string, error) { + return containers.ApplyImageLayersFromDirectory(ctx, wco.log, options, wco) +} diff --git a/internal/wslc/images_test.go b/internal/wslc/images_test.go new file mode 100644 index 00000000..41e6e98f --- /dev/null +++ b/internal/wslc/images_test.go @@ -0,0 +1,305 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "encoding/base64" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/microsoft/dcp/internal/containers" + internal_testutil "github.com/microsoft/dcp/internal/testutil" + usvc_io "github.com/microsoft/dcp/pkg/io" + "github.com/microsoft/dcp/pkg/osutil" +) + +func TestBuildImageUsesDirectoryContextPlainProgressAndIIDFile(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + iidFile := filepath.Join(t.TempDir(), "image.iid") + validImageID := "sha256:" + strings.Repeat("a", 64) + contextPath := `C:\build context` + dockerfilePath := `C:\build context\Containerfile` + expectedCommand := []string{ + "wslc", "image", "build", + "--file", dockerfilePath, + "--pull", + "--iidfile", iidFile, + "--tag", "example.test/image:tag", + "--build-arg", "ARG=value", + "--secret", `id=file-secret,type=file,src=C:\secrets\file`, + "--secret", "id=env-secret,type=env,env=SECRET_ENV", + "--target", "final", + "--label", "owner=dcp", + "--progress", "plain", + contextPath, + } + executor.InstallAutoExecution(internal_testutil.AutoExecution{ + Condition: internal_testutil.ProcessSearchCriteria{Command: expectedCommand}, + RunCommand: func(execution *internal_testutil.ProcessExecution) int32 { + require.NotContains(t, strings.Join(execution.Cmd.Args, " "), "secret-value") + require.Contains(t, execution.Cmd.Environ(), "SECRET_ENV=secret-value") + writeErr := usvc_io.WriteFile(iidFile, []byte(validImageID+"\n"), osutil.PermissionOnlyOwnerReadWrite) + require.NoError(t, writeErr) + _, stderrErr := execution.Cmd.Stderr.Write([]byte("build progress\n")) + require.NoError(t, stderrErr) + return 0 + }, + }) + + buildErr := orchestrator.BuildImage(ctx, containers.BuildImageOptions{ + IidFile: iidFile, + Pull: true, + ContainerBuildContext: &containers.ContainerBuildContext{ + Context: contextPath, + Dockerfile: dockerfilePath, + Tags: []string{"example.test/image:tag"}, + Args: []containers.EnvVar{{Name: "ARG", Value: "value"}}, + Secrets: []containers.ContainerBuildSecret{ + {ID: "file-secret", Type: containers.FileSecret, Source: `C:\secrets\file`}, + {ID: "env-secret", Type: containers.EnvSecret, Source: "SECRET_ENV", Value: "secret-value"}, + }, + Stage: "final", + Labels: []containers.Label{ + {Key: "owner", Value: "old"}, + {Key: "owner", Value: "dcp"}, + }, + }, + }) + + require.NoError(t, buildErr) + require.Len(t, executor.FindAll(expectedCommand, "", nil), 1) +} + +func TestBuildImageRejectsUnsupportedPlatformAndMissingIID(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + platformErr := orchestrator.BuildImage(ctx, containers.BuildImageOptions{ + ContainerBuildContext: &containers.ContainerBuildContext{ + Context: `C:\context`, + Platform: "linux/amd64", + }, + }) + require.ErrorContains(t, platformErr, "does not support selecting build platform") + require.Empty(t, executor.Executions) + + iidFile := filepath.Join(t.TempDir(), "missing.iid") + installAutoCommand( + t, + executor, + []string{"wslc", "image", "build", "--iidfile", iidFile, "--progress", "plain", `C:\context`}, + "", + "", + 0, + ) + iidErr := orchestrator.BuildImage(ctx, containers.BuildImageOptions{ + IidFile: iidFile, + ContainerBuildContext: &containers.ContainerBuildContext{ + Context: `C:\context`, + }, + }) + require.ErrorContains(t, iidErr, "inspecting image ID file") +} + +func TestBuildImageRejectsInvalidIIDFileResults(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + contents string + nonRegular bool + errorContains string + }{ + { + name: "short digest", + contents: "sha256:" + strings.Repeat("a", 63), + errorContains: "expected sha256: followed by 64 hexadecimal characters", + }, + { + name: "nonhex digest", + contents: "sha256:" + strings.Repeat("a", 63) + "g", + errorContains: "decoding SHA256 value", + }, + { + name: "oversized output", + contents: strings.Repeat("a", 1025), + errorContains: "exceeds 1024 bytes", + }, + { + name: "nonregular output", + nonRegular: true, + errorContains: "is not a regular file", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + iidPath := filepath.Join(t.TempDir(), "image.iid") + if testCase.nonRegular { + iidPath = t.TempDir() + } + + expectedCommand := []string{ + "wslc", "image", "build", + "--iidfile", iidPath, + "--progress", "plain", + `C:\context`, + } + executor.InstallAutoExecution(internal_testutil.AutoExecution{ + Condition: internal_testutil.ProcessSearchCriteria{Command: expectedCommand}, + RunCommand: func(*internal_testutil.ProcessExecution) int32 { + if testCase.nonRegular { + return 0 + } + writeErr := usvc_io.WriteFile( + iidPath, + []byte(testCase.contents), + osutil.PermissionOnlyOwnerReadWrite, + ) + require.NoError(t, writeErr) + return 0 + }, + }) + + buildErr := orchestrator.BuildImage(ctx, containers.BuildImageOptions{ + IidFile: iidPath, + ContainerBuildContext: &containers.ContainerBuildContext{ + Context: `C:\context`, + }, + }) + + require.ErrorContains(t, buildErr, testCase.errorContains) + }) + } +} + +func TestInspectImagesPreservesPartialResultsFromFailedCommand(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "image", "inspect", "--format", "json", "present", "missing"}, + `[{"Id":"sha256:image-id","RepoTags":["present:latest"],"RepoDigests":["present@sha256:digest"],"Config":{"Labels":{"owner":"dcp"}}}]`, + "Image 'missing' not found.\n", + 1, + ) + + inspected, inspectErr := orchestrator.InspectImages(ctx, containers.InspectImagesOptions{ + Images: []string{"present", "missing"}, + }) + + require.Len(t, inspected, 1) + require.Equal(t, "sha256:image-id", inspected[0].Id) + require.Equal(t, "sha256:digest", inspected[0].Digest) + require.Equal(t, "dcp", inspected[0].Labels["owner"]) + require.ErrorIs(t, inspectErr, containers.ErrNotFound) + require.ErrorIs(t, inspectErr, containers.ErrIncomplete) +} + +func TestPullImageFallsBackToInspectionWhenQuietOutputIsEmpty(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "image", "pull", "--quiet", "example.test/image:tag"}, + "", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "image", "inspect", "--format", "json", "example.test/image:tag"}, + `[{"Id":"sha256:pulled-image","RepoTags":["example.test/image:tag"],"Config":{}}]`, + "", + 0, + ) + + imageID, pullErr := orchestrator.PullImage(ctx, containers.PullImageOptions{ + Image: "example.test/image:tag", + }) + + require.NoError(t, pullErr) + require.Equal(t, "sha256:pulled-image", imageID) +} + +func TestRemoveImagesReturnsRequestedReferencesWithPartialErrors(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "image", "remove", "--force", "first:latest"}, + "Deleted: sha256:first\n", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "image", "remove", "--force", "missing:latest"}, + "", + "Image 'missing:latest' not found.\n", + 1, + ) + + removed, removeErr := orchestrator.RemoveImages(ctx, containers.RemoveImagesOptions{ + Images: []string{"first:latest", "missing:latest"}, + Force: true, + }) + + require.Equal(t, []string{"first:latest"}, removed) + require.True(t, errors.Is(removeErr, containers.ErrNotFound)) + require.True(t, errors.Is(removeErr, containers.ErrIncomplete)) +} + +func TestApplyImageLayersUsesDiskBackedBuildContext(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + executor.InstallAutoExecution(internal_testutil.AutoExecution{ + Condition: internal_testutil.ProcessSearchCriteria{ + Command: []string{"wslc", "image", "build", "--file"}, + Cond: func(execution *internal_testutil.ProcessExecution) bool { + return strings.Contains(strings.Join(execution.Cmd.Args, " "), "--tag derived:latest") && + strings.Contains(strings.Join(execution.Cmd.Args, " "), "--progress plain") + }, + }, + RunCommand: func(*internal_testutil.ProcessExecution) int32 { + return 0 + }, + }) + + imageReference, applyErr := orchestrator.ApplyImageLayers(ctx, containers.ApplyImageLayersOptions{ + BaseImage: containers.InspectedImage{ + Id: "sha256:base", + Tags: []string{"base:latest"}, + }, + Layers: []containers.ImageLayer{{ + Digest: "layer", + RawContents: base64.StdEncoding.EncodeToString([]byte("opaque tar bytes")), + }}, + Tag: "derived:latest", + }) + + require.NoError(t, applyErr) + require.Equal(t, "derived:latest", imageReference) + require.Len(t, executor.FindAll([]string{"wslc", "image", "build", "--file"}, "", nil), 1) +} diff --git a/internal/wslc/networks.go b/internal/wslc/networks.go new file mode 100644 index 00000000..47128c72 --- /dev/null +++ b/internal/wslc/networks.go @@ -0,0 +1,534 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/microsoft/dcp/internal/containers" +) + +func (wco *WslcCliOrchestrator) CreateNetwork(ctx context.Context, options containers.CreateNetworkOptions) (string, error) { + if options.Name == "" { + return "", fmt.Errorf("must specify a network name") + } + if options.IPv6 { + return "", fmt.Errorf("wslc does not support enabling IPv6 when creating a network") + } + + args := []string{"network", "create"} + labelKeys := make([]string, 0, len(options.Labels)) + for key := range options.Labels { + labelKeys = append(labelKeys, key) + } + sort.Strings(labelKeys) + for _, key := range labelKeys { + if key == "" { + return "", fmt.Errorf("network label key cannot be empty") + } + args = append(args, "--label", key+"="+options.Labels[key]) + } + args = append(args, options.Name) + + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "CreateNetwork", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return "", errors.Join( + runErr, + normalizeCliErrors(errBuf, networkNotFoundMatch, alreadyExistsMatch, allocationFailureMatch), + ) + } + + outputName, outputErr := parseSingleIdentifier(outBuf) + if outputErr == nil && outputName != options.Name { + outputErr = fmt.Errorf("wslc network create returned name %q instead of %q", outputName, options.Name) + } + + createdNetwork, inspectErr := wco.resolveNetwork(ctx, options.Name) + if inspectErr != nil { + return "", errors.Join(outputErr, fmt.Errorf("inspecting newly created WSLC network %q: %w", options.Name, inspectErr)) + } + if createdNetwork.Id == "" { + return "", errors.Join(outputErr, fmt.Errorf("newly created WSLC network %q did not report an ID", options.Name)) + } + return createdNetwork.Id, outputErr +} + +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") + } + + return runSequentially(ctx, "networks", options.Networks, func(networkReference string) error { + network, resolveErr := wco.resolveNetwork(ctx, networkReference) + if resolveErr != nil { + return resolveErr + } + + args := []string{"network", "remove"} + if options.Force { + args = append(args, "--force") + } + args = append(args, network.Name) + + cmd := makeWslcCommand(args...) + _, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "RemoveNetwork", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return errors.Join(runErr, normalizeCliErrors(errBuf, networkNotFoundMatch, objectInUseMatch)) + } + return nil + }) +} + +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") + } + + rawNetworks, inspectErr := wco.inspectNetworksRaw(ctx, options.Networks) + inspectedNetworks := make([]containers.InspectedNetwork, 0, len(rawNetworks)) + var conversionErr error + for _, rawNetwork := range rawNetworks { + inspectedNetwork, convertErr := convertInspectedNetwork(rawNetwork) + if convertErr != nil { + conversionErr = errors.Join(conversionErr, containers.ErrUnmarshalling, convertErr) + continue + } + inspectedNetworks = append(inspectedNetworks, inspectedNetwork) + } + + return inspectedNetworks, errors.Join( + inspectErr, + conversionErr, + incompleteError("networks", len(inspectedNetworks), len(options.Networks)), + ) +} + +func (wco *WslcCliOrchestrator) inspectNetworksRaw( + ctx context.Context, + networkReferences []string, +) ([]wslcInspectedNetwork, error) { + args := append([]string{"network", "inspect", "--format", "json"}, networkReferences...) + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "InspectNetworks", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + + rawNetworks, decodeErr := decodeJSONArray[wslcInspectedNetwork](outBuf) + if runErr != nil { + runErr = errors.Join( + runErr, + normalizeCliErrors(errBuf, networkNotFoundMatch.MaxObjects(len(networkReferences))), + ) + } + return rawNetworks, errors.Join(runErr, decodeErr) +} + +func convertInspectedNetwork(rawNetwork wslcInspectedNetwork) (containers.InspectedNetwork, error) { + networkID := strings.TrimSpace(rawNetwork.ID) + networkName := strings.TrimSpace(rawNetwork.Name) + if networkID == "" { + return containers.InspectedNetwork{}, fmt.Errorf("inspected WSLC network did not contain an ID") + } + if networkName == "" { + return containers.InspectedNetwork{}, fmt.Errorf("inspected WSLC network %q did not contain a name", networkID) + } + + inspectedNetwork := containers.InspectedNetwork{ + Name: networkName, + Id: networkID, + Driver: rawNetwork.Driver, + Labels: rawNetwork.Labels, + Scope: rawNetwork.Scope, + IPv6: bool(rawNetwork.EnableIPv6) || bool(rawNetwork.IPv6), + Internal: bool(rawNetwork.Internal), + Attachable: bool(rawNetwork.Attachable), + Ingress: bool(rawNetwork.Ingress), + CreatedAt: rawNetwork.Created.Time, + } + for _, config := range rawNetwork.IPAM.Config { + if config.Subnet != "" { + inspectedNetwork.Subnets = append(inspectedNetwork.Subnets, config.Subnet) + } + if config.Gateway != "" { + inspectedNetwork.Gateways = append(inspectedNetwork.Gateways, config.Gateway) + } + } + + containerIDs := make([]string, 0, len(rawNetwork.Containers)) + for containerID := range rawNetwork.Containers { + containerIDs = append(containerIDs, containerID) + } + sort.Strings(containerIDs) + for _, containerID := range containerIDs { + inspectedNetwork.Containers = append(inspectedNetwork.Containers, containers.InspectedNetworkContainer{ + Id: containerID, + Name: rawNetwork.Containers[containerID].Name, + }) + } + + return inspectedNetwork, nil +} + +func (wco *WslcCliOrchestrator) ConnectNetwork(ctx context.Context, options containers.ConnectNetworkOptions) error { + if options.Container == "" { + return fmt.Errorf("must specify a container") + } + network, resolveErr := wco.resolveNetwork(ctx, options.Network) + if resolveErr != nil { + return resolveErr + } + + args := []string{"network", "connect"} + for _, alias := range options.Aliases { + if alias == "" { + return fmt.Errorf("network alias cannot be empty") + } + args = append(args, "--network-alias", alias) + } + args = append(args, network.Name, options.Container) + + cmd := makeWslcCommand(args...) + _, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "ConnectNetwork", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return errors.Join( + runErr, + normalizeCliErrors(errBuf, containerNotFoundMatch, networkNotFoundMatch, alreadyExistsMatch), + ) + } + return nil +} + +func (wco *WslcCliOrchestrator) DisconnectNetwork(ctx context.Context, options containers.DisconnectNetworkOptions) error { + if options.Container == "" { + return fmt.Errorf("must specify a container") + } + network, resolveErr := wco.resolveNetwork(ctx, options.Network) + if resolveErr != nil { + return resolveErr + } + + cmd := makeWslcCommand("network", "disconnect", network.Name, options.Container) + _, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "DisconnectNetwork", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + runErr = errors.Join(runErr, normalizeCliErrors(errBuf, containerNotFoundMatch, networkNotFoundMatch)) + } + + if !options.Force { + return runErr + } + + verifyErr := wco.verifyNetworkDetached(ctx, network, options.Container) + if verifyErr == nil { + return nil + } + return errors.Join(runErr, verifyErr) +} + +func (wco *WslcCliOrchestrator) verifyNetworkDetached( + ctx context.Context, + network containers.InspectedNetwork, + containerReference string, +) error { + rawContainers, containerInspectErr := wco.inspectContainersRaw(ctx, []string{containerReference}) + + containerID := containerReference + containerName := containerReference + switch len(rawContainers) { + case 1: + if containerInspectErr != nil { + return fmt.Errorf( + "verifying WSLC container network configuration returned an object with an error: %w", + containerInspectErr, + ) + } + rawContainer := rawContainers[0] + containerID = strings.TrimSpace(rawContainer.ID) + if containerID == "" { + return fmt.Errorf( + "wslc could not verify forced disconnect: container %q inspection returned an empty ID", + containerReference, + ) + } + containerName = strings.TrimPrefix(strings.TrimSpace(rawContainer.Name), "/") + if containerName == "" { + return fmt.Errorf( + "wslc could not verify forced disconnect: container %q inspection returned an empty name", + containerReference, + ) + } + if _, attached := rawContainer.NetworkSettings.Networks[network.Name]; attached { + return fmt.Errorf( + "wslc could not verify forced disconnect: network %q remains in container %q configuration", + network.Name, + containerReference, + ) + } + case 0: + if !isTrustworthyNotFound(containerInspectErr) { + return errors.Join( + containerInspectErr, + fmt.Errorf( + "wslc could not verify forced disconnect: container %q inspection returned no object", + containerReference, + ), + ) + } + default: + return errors.Join( + containerInspectErr, + fmt.Errorf( + "wslc could not verify forced disconnect: container %q inspection returned %d objects", + containerReference, + len(rawContainers), + ), + ) + } + + rawNetworks, networkInspectErr := wco.inspectNetworksRaw(ctx, []string{network.Name}) + if len(rawNetworks) == 0 { + if isTrustworthyNotFound(networkInspectErr) { + return nil + } + return errors.Join( + networkInspectErr, + fmt.Errorf( + "wslc could not verify forced disconnect: network %q inspection returned no object", + network.Name, + ), + ) + } + if len(rawNetworks) != 1 { + return errors.Join( + networkInspectErr, + fmt.Errorf( + "wslc could not verify forced disconnect: network %q inspection returned %d objects", + network.Name, + len(rawNetworks), + ), + ) + } + if networkInspectErr != nil { + return fmt.Errorf("verifying WSLC network endpoints: %w", networkInspectErr) + } + postNetworkID := strings.TrimSpace(rawNetworks[0].ID) + if postNetworkID == "" { + return fmt.Errorf( + "wslc could not verify forced disconnect: network %q inspection returned an empty ID", + network.Name, + ) + } + if postNetworkID != network.Id { + return fmt.Errorf( + "wslc could not verify forced disconnect: network %q identity changed from %q to %q", + network.Name, + network.Id, + postNetworkID, + ) + } + postNetworkName := strings.TrimSpace(rawNetworks[0].Name) + if postNetworkName == "" { + return fmt.Errorf( + "wslc could not verify forced disconnect: network %q inspection returned an empty name", + network.Name, + ) + } + if postNetworkName != network.Name { + return fmt.Errorf( + "wslc could not verify forced disconnect: network %q inspection returned name %q", + network.Name, + postNetworkName, + ) + } + + for endpointID, endpoint := range rawNetworks[0].Containers { + if identifiersMatch(endpointID, containerID) || + endpoint.Name == containerName || + endpoint.Name == containerReference { + return fmt.Errorf( + "wslc could not verify forced disconnect: network %q still reports an active endpoint for container %q", + network.Name, + containerReference, + ) + } + } + return nil +} + +func isTrustworthyNotFound(err error) bool { + if err == nil || !errors.Is(err, containers.ErrNotFound) { + return false + } + + untrustworthyErrors := []error{ + context.Canceled, + context.DeadlineExceeded, + containers.ErrUnmatched, + containers.ErrUnmarshalling, + containers.ErrIncomplete, + containers.ErrRuntimeNotHealthy, + containers.ErrAlreadyExists, + containers.ErrCouldNotAllocate, + containers.ErrObjectInUse, + } + for _, untrustworthyErr := range untrustworthyErrors { + if errors.Is(err, untrustworthyErr) { + return false + } + } + return true +} + +func identifiersMatch(first string, second string) bool { + if first == "" || second == "" { + return false + } + if first == second { + return true + } + const minimumPrefixLength = 12 + return len(first) >= minimumPrefixLength && + len(second) >= minimumPrefixLength && + (strings.HasPrefix(first, second) || strings.HasPrefix(second, first)) +} + +func (wco *WslcCliOrchestrator) ListNetworks(ctx context.Context, options containers.ListNetworksOptions) ([]containers.ListedNetwork, error) { + args := []string{"network", "list", "--no-trunc"} + for _, label := range options.Filters.LabelFilters { + filter := "label=" + label.Key + if label.Value != "" { + filter += "=" + label.Value + } + args = append(args, "--filter", filter) + } + args = append(args, "--format", "json") + + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "ListNetworks", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return nil, errors.Join(runErr, normalizeCliErrors(errBuf)) + } + + rawNetworks, decodeErr := decodeJSONLines[wslcListedNetwork](outBuf) + listedNetworks := make([]containers.ListedNetwork, 0, len(rawNetworks)) + labelInspectionIDs := make([]string, 0, len(rawNetworks)) + for _, rawNetwork := range rawNetworks { + if rawNetwork.ID == "" || rawNetwork.Name == "" { + decodeErr = errors.Join( + decodeErr, + containers.ErrUnmarshalling, + fmt.Errorf("listed WSLC network did not contain both an ID and name"), + ) + continue + } + listedNetworks = append(listedNetworks, containers.ListedNetwork{ + Driver: rawNetwork.Driver, + ID: rawNetwork.ID, + IPv6: bool(rawNetwork.IPv6), + Internal: bool(rawNetwork.Internal), + Name: rawNetwork.Name, + }) + labelInspectionIDs = append(labelInspectionIDs, rawNetwork.ID) + } + + if len(labelInspectionIDs) > 0 { + inspectedNetworks, inspectErr := wco.InspectNetworks(ctx, containers.InspectNetworksOptions{ + Networks: labelInspectionIDs, + }) + labelsByID := make(map[string]map[string]string, len(inspectedNetworks)) + for _, inspectedNetwork := range inspectedNetworks { + labelsByID[inspectedNetwork.Id] = inspectedNetwork.Labels + } + for index := range listedNetworks { + listedNetworks[index].Labels = labelsByID[listedNetworks[index].ID] + } + if inspectErr != nil || len(inspectedNetworks) < len(labelInspectionIDs) { + decodeErr = errors.Join( + decodeErr, + fmt.Errorf("resolving authoritative labels for listed WSLC networks: %w", + errors.Join(inspectErr, incompleteError("networks", len(inspectedNetworks), len(labelInspectionIDs)))), + ) + } + } + + return listedNetworks, decodeErr +} + +func (wco *WslcCliOrchestrator) resolveNetwork( + ctx context.Context, + networkReference string, +) (containers.InspectedNetwork, error) { + if networkReference == "" { + return containers.InspectedNetwork{}, fmt.Errorf("must specify a network") + } + + inspectedNetworks, inspectErr := wco.InspectNetworks(ctx, containers.InspectNetworksOptions{ + Networks: []string{networkReference}, + }) + if inspectErr != nil { + return containers.InspectedNetwork{}, inspectErr + } + if len(inspectedNetworks) != 1 { + return containers.InspectedNetwork{}, fmt.Errorf( + "wslc network lookup for %q returned %d objects", + networkReference, + len(inspectedNetworks), + ) + } + return inspectedNetworks[0], nil +} + +func (*WslcCliOrchestrator) DefaultNetworkName() string { + return "bridge" +} + +func (*WslcCliOrchestrator) IsBuiltInNetwork(networkName string) bool { + return networkName == "bridge" || networkName == "host" || networkName == "none" +} diff --git a/internal/wslc/networks_test.go b/internal/wslc/networks_test.go new file mode 100644 index 00000000..309ccef9 --- /dev/null +++ b/internal/wslc/networks_test.go @@ -0,0 +1,691 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/microsoft/dcp/internal/containers" +) + +func TestCreateNetworkReturnsInspectedFullID(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "create", "--label", "owner=dcp", "network-name"}, + "network-name\n", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-name"}, + `[{"Id":"full-network-id","Name":"network-name","Driver":"bridge","Scope":"local","IPAM":{"Config":[]},"Labels":{"owner":"dcp"},"Containers":{}}]`, + "", + 0, + ) + + networkID, createErr := orchestrator.CreateNetwork(ctx, containers.CreateNetworkOptions{ + Name: "network-name", + Labels: map[string]string{"owner": "dcp"}, + }) + + require.NoError(t, createErr) + require.Equal(t, "full-network-id", networkID) +} + +func TestCreateNetworkRejectsIPv6WithoutInvokingCli(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + _, createErr := orchestrator.CreateNetwork(ctx, containers.CreateNetworkOptions{ + Name: "network-name", + IPv6: true, + }) + + require.ErrorContains(t, createErr, "does not support enabling IPv6") + require.Empty(t, executor.Executions) +} + +func TestInspectNetworksMapsDockerLikeWslcShape(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-name"}, + `[{ + "Id":"network-id", + "Name":"network-name", + "Created":"2026-01-02T03:04:05Z", + "Scope":"local", + "Driver":"bridge", + "EnableIPv6":true, + "Internal":false, + "Attachable":true, + "Ingress":false, + "IPAM":{"Config":[{"Subnet":"172.20.0.0/16","Gateway":"172.20.0.1"}]}, + "Labels":{"owner":"dcp"}, + "Containers":{"container-id":{"Name":"container-name"}} + }]`, + "", + 0, + ) + + inspected, inspectErr := orchestrator.InspectNetworks(ctx, containers.InspectNetworksOptions{ + Networks: []string{"network-name"}, + }) + + require.NoError(t, inspectErr) + require.Len(t, inspected, 1) + require.Equal(t, "network-id", inspected[0].Id) + require.Equal(t, "network-name", inspected[0].Name) + require.True(t, inspected[0].IPv6) + require.True(t, inspected[0].Attachable) + require.Equal(t, []string{"172.20.0.0/16"}, inspected[0].Subnets) + require.Equal(t, []string{"172.20.0.1"}, inspected[0].Gateways) + require.Equal(t, containers.InspectedNetworkContainer{ + Id: "container-id", + Name: "container-name", + }, inspected[0].Containers[0]) +} + +func TestInspectNetworksPreservesValidObjectAlongsideMissingReference(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "present", "missing"}, + `[{"Id":"network-id","Name":"present","Driver":"bridge","IPAM":{"Config":[]},"Containers":{}}]`, + "Network not found: 'missing'\n", + 1, + ) + + inspected, inspectErr := orchestrator.InspectNetworks(ctx, containers.InspectNetworksOptions{ + Networks: []string{"present", "missing"}, + }) + + require.Len(t, inspected, 1) + require.Equal(t, "network-id", inspected[0].Id) + require.ErrorIs(t, inspectErr, containers.ErrNotFound) + require.ErrorIs(t, inspectErr, containers.ErrIncomplete) +} + +func TestRemoveNetworkResolvesIDToNativeNameAndReturnsRequestedID(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "remove", "--force", "network-name"}, + "network-name\n", + "", + 0, + ) + + removed, removeErr := orchestrator.RemoveNetworks(ctx, containers.RemoveNetworksOptions{ + Networks: []string{"network-id"}, + Force: true, + }) + + require.NoError(t, removeErr) + require.Equal(t, []string{"network-id"}, removed) + require.Empty(t, executor.FindAll([]string{"wslc", "network", "remove", "--force", "network-id"}, "", nil)) +} + +func TestConnectNetworkUsesResolvedNameAndNativeAliasFlag(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "connect", "--network-alias", "one", "--network-alias", "two", "network-name", "container"}, + "", + "", + 0, + ) + + connectErr := orchestrator.ConnectNetwork(ctx, containers.ConnectNetworkOptions{ + Network: "network-id", + Container: "container", + Aliases: []string{"one", "two"}, + }) + + require.NoError(t, connectErr) +} + +func TestForcedDisconnectAcceptsAlreadyDetachedContainerAfterVerification(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container"}, + "", + "container is not connected to network\n", + 1, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container"}, + `[{"Id":"container-id","Name":"/container","State":{"Status":"running"},"NetworkSettings":{"Networks":{}}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-name"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container", + Force: true, + }) + + require.NoError(t, disconnectErr) + require.Empty(t, executor.FindAll([]string{"wslc", "network", "disconnect", "--force"}, "", nil)) +} + +func TestForcedDisconnectRejectsDetachedContainerReturnedWithInspectError(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container"}, + "", + "disconnect failed\n", + 1, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container"}, + `[{"Id":"container-id","Name":"/container","NetworkSettings":{"Networks":{}}}]`, + "container inspection failed\n", + 1, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container", + Force: true, + }) + + require.ErrorContains(t, disconnectErr, "disconnect failed") + require.ErrorContains(t, disconnectErr, "returned an object with an error") + require.ErrorContains(t, disconnectErr, "container inspection failed") +} + +func TestForcedDisconnectVerifiesExitedContainerConfiguration(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container-id"}, + "", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container-id"}, + `[{"Id":"container-id","Name":"/container-name","State":{"Status":"exited"},"NetworkSettings":{"Networks":{}}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-name"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container-id", + Force: true, + }) + + require.NoError(t, disconnectErr) +} + +func TestForcedDisconnectRejectsPostNetworkWithoutID(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container"}, + "", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container"}, + `[{"Id":"container-id","Name":"/container","NetworkSettings":{"Networks":{}}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-name"}, + `[{"Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container", + Force: true, + }) + + require.ErrorContains(t, disconnectErr, "inspection returned an empty ID") +} + +func TestForcedDisconnectRejectsChangedNetworkIdentity(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container"}, + "", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container"}, + `[{"Id":"container-id","Name":"/container","NetworkSettings":{"Networks":{}}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-name"}, + `[{"Id":"replacement-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container", + Force: true, + }) + + require.ErrorContains(t, disconnectErr, `identity changed from "network-id" to "replacement-id"`) +} + +func TestForcedDisconnectReportsIncompleteContainerConfiguration(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container"}, + "", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container"}, + `[{"Id":"container-id","Name":"/container","NetworkSettings":{"Networks":{"network-name":{}}}}]`, + "", + 0, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container", + Force: true, + }) + + require.ErrorContains(t, disconnectErr, "remains in container") +} + +func TestForcedDisconnectDoesNotTreatEmptyContainerInspectionAsVerified(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container"}, + "", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container"}, + `[]`, + "", + 0, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container", + Force: true, + }) + + require.ErrorContains(t, disconnectErr, "inspection returned no object") +} + +func TestForcedDisconnectRejectsMalformedContainerInspection(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container"}, + "", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container"}, + `[{}]`, + "", + 0, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container", + Force: true, + }) + + require.ErrorContains(t, disconnectErr, "inspection returned an empty ID") +} + +func TestForcedDisconnectAcceptsGenuineMissingContainer(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container"}, + "", + "Container 'container' not found.\n", + 1, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container"}, + `[]`, + "Container 'container' not found.\n", + 1, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-name"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container", + Force: true, + }) + + require.NoError(t, disconnectErr) +} + +func TestForcedDisconnectAcceptsGenuineMissingNetworkAfterDetach(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "disconnect", "network-name", "container"}, + "", + "Network not found: 'network-name'\n", + 1, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "container", "inspect", "--format", "json", "container"}, + `[{"Id":"container-id","Name":"/container","NetworkSettings":{"Networks":{}}}]`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-name"}, + `[]`, + "Network not found: 'network-name'\n", + 1, + ) + + disconnectErr := orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: "network-id", + Container: "container", + Force: true, + }) + + require.NoError(t, disconnectErr) +} + +func TestListNetworksUsesInspectionForAuthoritativeLabels(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "list", "--no-trunc", "--filter", "label=owner=dcp", "--format", "json"}, + `{"Driver":"bridge","ID":"network-id","IPv6":"false","Internal":"false","Labels":"owner=dcp,metadata={\"one\":1,\"two\":2}","Name":"network-name"}`+"\n", + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "network-id"}, + `[{"Id":"network-id","Name":"network-name","Driver":"bridge","IPAM":{"Config":[]},"Labels":{"owner":"dcp","value":"one,two=three"},"Containers":{}}]`, + "", + 0, + ) + + listed, listErr := orchestrator.ListNetworks(ctx, containers.ListNetworksOptions{ + Filters: containers.ListNetworksFilters{ + LabelFilters: []containers.LabelFilter{{Key: "owner", Value: "dcp"}}, + }, + }) + + require.NoError(t, listErr) + require.Len(t, listed, 1) + require.Equal(t, "network-id", listed[0].ID) + require.Equal(t, "one,two=three", listed[0].Labels["value"]) +} + +func TestRemoveNetworksPreservesPartialResults(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "first"}, + `[{"Id":"first-id","Name":"first","IPAM":{"Config":[]},"Containers":{}}]`, + "", + 0, + ) + installAutoCommand(t, executor, []string{"wslc", "network", "remove", "first"}, "first\n", "", 0) + installAutoCommand( + t, + executor, + []string{"wslc", "network", "inspect", "--format", "json", "missing"}, + `[]`, + "Network not found: 'missing'\n", + 1, + ) + + removed, removeErr := orchestrator.RemoveNetworks(ctx, containers.RemoveNetworksOptions{ + Networks: []string{"first", "missing"}, + }) + + require.Equal(t, []string{"first"}, removed) + require.True(t, errors.Is(removeErr, containers.ErrNotFound)) + require.True(t, errors.Is(removeErr, containers.ErrIncomplete)) +} + +func TestIsBuiltInNetwork(t *testing.T) { + t.Parallel() + + orchestrator := &WslcCliOrchestrator{} + require.True(t, orchestrator.IsBuiltInNetwork("bridge")) + require.True(t, orchestrator.IsBuiltInNetwork("host")) + require.True(t, orchestrator.IsBuiltInNetwork("none")) + require.False(t, orchestrator.IsBuiltInNetwork("application")) +} diff --git a/internal/wslc/orchestrator.go b/internal/wslc/orchestrator.go new file mode 100644 index 00000000..618b49e1 --- /dev/null +++ b/internal/wslc/orchestrator.go @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/go-logr/logr" + + "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/pkg/concurrency" + "github.com/microsoft/dcp/pkg/process" +) + +const ( + ordinaryCommandTimeout = 30 * time.Second + versionCommandTimeout = 2 * time.Second + diagnosticCommandTimeout = time.Minute + defaultBuildTimeout = 10 * time.Minute + defaultPullTimeout = 10 * time.Minute + defaultCreateTimeout = 10 * time.Minute + defaultRunTimeout = 10 * time.Minute + statusRefreshInterval = 5 * time.Second +) + +type WslcCliOrchestrator struct { + log logr.Logger + executor process.Executor + + cachedStatus *containers.ContainerRuntimeStatus + checkStatusLock *concurrency.ContextAwareLock + statusLock sync.RWMutex + statusWorker atomic.Int32 +} + +func NewWslcCliOrchestrator(log logr.Logger, executor process.Executor) containers.ContainerOrchestrator { + return &WslcCliOrchestrator{ + log: log, + executor: executor, + checkStatusLock: concurrency.NewContextAwareLock(), + } +} + +func (*WslcCliOrchestrator) IsDefault() bool { + return false +} + +func (*WslcCliOrchestrator) Name() string { + return "wslc" +} + +func (*WslcCliOrchestrator) ContainerHost() string { + return "" +} + +func (wco *WslcCliOrchestrator) CheckStatus(ctx context.Context, cacheUsage containers.CachedRuntimeStatusUsage) containers.ContainerRuntimeStatus { + wco.statusLock.RLock() + if wco.cachedStatus != nil && cacheUsage == containers.CachedRuntimeStatusAllowed { + status := *wco.cachedStatus + wco.statusLock.RUnlock() + return status + } + wco.statusLock.RUnlock() + + if cacheUsage == containers.CachedRuntimeStatusAllowed { + if lockErr := wco.checkStatusLock.Lock(ctx); lockErr != nil { + return containers.ContainerRuntimeStatus{ + Error: "timed out while checking WSLC status; the WSLC CLI is not responsive", + } + } + defer wco.checkStatusLock.Unlock() + + wco.statusLock.RLock() + if wco.cachedStatus != nil { + status := *wco.cachedStatus + wco.statusLock.RUnlock() + return status + } + wco.statusLock.RUnlock() + } + + status := wco.getStatusForOS(ctx, runtime.GOOS) + wco.storeStatus(status) + return status +} + +func (wco *WslcCliOrchestrator) EnsureBackgroundStatusUpdates(ctx context.Context) { + if ctx.Err() != nil { + return + } + if !wco.statusWorker.CompareAndSwap(0, 1) { + return + } + + go func() { + defer wco.statusWorker.Store(0) + + timer := time.NewTimer(0) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + } + if ctx.Err() != nil { + return + } + + if wco.checkStatusLock.TryLock() { + status := wco.getStatusForOS(ctx, runtime.GOOS) + wco.storeStatus(status) + wco.checkStatusLock.Unlock() + } + + timer.Reset(statusRefreshInterval) + } + }() +} + +func (wco *WslcCliOrchestrator) storeStatus(status containers.ContainerRuntimeStatus) { + wco.statusLock.Lock() + wco.cachedStatus = &status + wco.statusLock.Unlock() +} + +func (wco *WslcCliOrchestrator) getStatusForOS(ctx context.Context, goos string) containers.ContainerRuntimeStatus { + if goos != "windows" { + return containers.ContainerRuntimeStatus{ + Error: "WSLC is only available on Windows hosts", + } + } + + versionCmd := makeWslcCommand("version", "--format", "json") + versionOut, versionErrOut, versionRunErr := wco.runBufferedWslcCommand( + ctx, + "Version", + versionCmd, + nil, + nil, + versionCommandTimeout, + ) + if versionRunErr != nil { + normalizedErr := errors.Join(versionRunErr, normalizeCliErrors(versionErrOut)) + if errors.Is(normalizedErr, exec.ErrNotFound) { + return containers.ContainerRuntimeStatus{ + Error: unwrapExecutableNotFound(normalizedErr).Error(), + } + } + + return containers.ContainerRuntimeStatus{ + Installed: true, + Error: preferredDiagnostic(normalizedErr, versionErrOut), + } + } + + var versionInfo wslcVersion + if versionDecodeErr := json.Unmarshal(versionOut.Bytes(), &versionInfo); versionDecodeErr != nil { + return containers.ContainerRuntimeStatus{ + Error: fmt.Sprintf("output from the WSLC version command was invalid: %v", versionDecodeErr), + } + } + if strings.TrimSpace(versionInfo.Client.Version) == "" { + return containers.ContainerRuntimeStatus{ + Error: "output from the WSLC version command did not contain a client version", + } + } + + infoCmd := makeWslcCommand("info", "--format", "json") + infoOut, infoErrOut, infoRunErr := wco.runBufferedWslcCommand( + ctx, + "Info", + infoCmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if infoRunErr != nil { + normalizedErr := errors.Join(infoRunErr, normalizeCliErrors(infoErrOut)) + return containers.ContainerRuntimeStatus{ + Installed: true, + Error: preferredDiagnostic(normalizedErr, infoErrOut), + } + } + + var info wslcInfo + if infoDecodeErr := json.Unmarshal(infoOut.Bytes(), &info); infoDecodeErr != nil { + return containers.ContainerRuntimeStatus{ + Installed: true, + Error: fmt.Sprintf("output from the WSLC info command was invalid: %v", infoDecodeErr), + } + } + if strings.TrimSpace(info.Client.Version) == "" { + return containers.ContainerRuntimeStatus{ + Installed: true, + Error: "output from the WSLC info command did not contain a client version", + } + } + if strings.TrimSpace(info.Server.SessionManagerVersion) == "" { + return containers.ContainerRuntimeStatus{ + Installed: true, + Error: "output from the WSLC info command did not contain a session manager version", + } + } + if len(info.Server.Sessions) == 0 { + return containers.ContainerRuntimeStatus{ + Installed: true, + Error: "WSLC is installed, but no default runtime session is available", + } + } + + return containers.ContainerRuntimeStatus{ + Installed: true, + Running: true, + } +} + +func (wco *WslcCliOrchestrator) GetDiagnostics(ctx context.Context) (containers.ContainerDiagnostics, error) { + return wco.getDiagnosticsForOS(ctx, runtime.GOOS) +} + +func (wco *WslcCliOrchestrator) getDiagnosticsForOS( + ctx context.Context, + goos string, +) (containers.ContainerDiagnostics, error) { + if goos != "windows" { + return containers.ContainerDiagnostics{}, fmt.Errorf("wslc diagnostics are unavailable on non-Windows hosts") + } + + cmd := makeWslcCommand("info", "--format", "json") + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "Info", + cmd, + nil, + nil, + diagnosticCommandTimeout, + ) + if runErr != nil { + return containers.ContainerDiagnostics{}, errors.Join(runErr, normalizeCliErrors(errBuf)) + } + + var info wslcInfo + if decodeErr := json.Unmarshal(outBuf.Bytes(), &info); decodeErr != nil { + return containers.ContainerDiagnostics{}, fmt.Errorf("decoding WSLC diagnostics: %w", decodeErr) + } + + clientVersion := strings.TrimSpace(info.Client.Version) + serverVersion := strings.TrimSpace(info.Server.SessionManagerVersion) + if clientVersion == "" || serverVersion == "" { + return containers.ContainerDiagnostics{}, fmt.Errorf( + "wslc diagnostics are incomplete: client version %q, session manager version %q", + clientVersion, + serverVersion, + ) + } + + return containers.ContainerDiagnostics{ + ClientVersion: clientVersion, + ServerVersion: serverVersion, + }, nil +} + +func unwrapExecutableNotFound(err error) error { + currentErr := err + for currentErr != nil { + unwrappedErr := errors.Unwrap(currentErr) + if unwrappedErr == nil || !errors.Is(unwrappedErr, exec.ErrNotFound) { + break + } + currentErr = unwrappedErr + } + return currentErr +} + +func preferredDiagnostic(runErr error, errBuf interface{ String() string }) string { + if errBuf != nil { + stderr := strings.TrimSpace(errBuf.String()) + if stderr != "" { + return stderr + } + } + if runErr == nil { + return "" + } + return runErr.Error() +} + +type wslcVersion struct { + Client wslcClientInfo `json:"Client"` +} + +type wslcInfo struct { + Client wslcClientInfo `json:"Client"` + Server wslcServerInfo `json:"Server"` +} + +type wslcClientInfo struct { + Version string `json:"Version"` +} + +type wslcServerInfo struct { + SessionManagerVersion string `json:"SessionManagerVersion"` + Sessions []wslcSession `json:"Sessions"` +} + +type wslcSession struct { + ID int64 `json:"ID"` + Name string `json:"Name"` +} + +var _ containers.ContainerOrchestrator = (*WslcCliOrchestrator)(nil) +var _ containers.VolumeOrchestrator = (*WslcCliOrchestrator)(nil) +var _ containers.ImageOrchestrator = (*WslcCliOrchestrator)(nil) +var _ containers.NetworkOrchestrator = (*WslcCliOrchestrator)(nil) diff --git a/internal/wslc/orchestrator_test.go b/internal/wslc/orchestrator_test.go new file mode 100644 index 00000000..1ee8d825 --- /dev/null +++ b/internal/wslc/orchestrator_test.go @@ -0,0 +1,254 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/microsoft/dcp/internal/containers" + internal_testutil "github.com/microsoft/dcp/internal/testutil" +) + +func TestOrchestratorIdentity(t *testing.T) { + t.Parallel() + + _, orchestrator, _ := newTestOrchestrator(t) + require.Equal(t, "wslc", orchestrator.Name()) + require.False(t, orchestrator.IsDefault()) + require.Empty(t, orchestrator.ContainerHost()) + require.Equal(t, "bridge", orchestrator.DefaultNetworkName()) +} + +func TestStatusHealthyWithDefaultSession(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "version", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"}}`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "info", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"},"Server":{"SessionManagerVersion":"2.9.11","Sessions":[{"ID":1,"Name":"default"}]}}`, + "", + 0, + ) + + status := orchestrator.getStatusForOS(ctx, "windows") + + require.True(t, status.Installed) + require.True(t, status.Running) + require.Empty(t, status.Error) +} + +func TestStatusDistinguishesInstalledFromRunning(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "version", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"}}`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "info", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"},"Server":{"SessionManagerVersion":"2.9.11","Sessions":[]}}`, + "", + 0, + ) + + status := orchestrator.getStatusForOS(ctx, "windows") + + require.True(t, status.Installed) + require.False(t, status.Running) + require.Contains(t, status.Error, "no default runtime session") +} + +func TestStatusRejectsInvalidVersionOutputAsNotInstalled(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "version", "--format", "json"}, + `{"Client":{}}`, + "", + 0, + ) + + status := orchestrator.getStatusForOS(ctx, "windows") + + require.False(t, status.Installed) + require.False(t, status.Running) + require.Contains(t, status.Error, "did not contain a client version") + require.Empty(t, executor.FindAll([]string{"wslc", "info"}, "", nil)) +} + +func TestStatusReportsMissingExecutableAsNotInstalled(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + executor.InstallAutoExecution(internal_testutil.AutoExecution{ + Condition: internal_testutil.ProcessSearchCriteria{ + Command: []string{"wslc", "version", "--format", "json"}, + }, + StartupError: func(*internal_testutil.ProcessExecution) error { + return exec.ErrNotFound + }, + }) + + status := orchestrator.getStatusForOS(ctx, "windows") + + require.False(t, status.Installed) + require.False(t, status.Running) + require.NotEmpty(t, status.Error) +} + +func TestStatusAndDiagnosticsAreUnavailableOffWindows(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + + status := orchestrator.getStatusForOS(ctx, "linux") + diagnostics, diagnosticsErr := orchestrator.getDiagnosticsForOS(ctx, "linux") + + require.False(t, status.Installed) + require.False(t, status.Running) + require.Contains(t, status.Error, "Windows") + require.Empty(t, diagnostics) + require.ErrorContains(t, diagnosticsErr, "non-Windows") + require.Empty(t, executor.Executions) +} + +func TestDiagnosticsDecodeClientAndSessionManagerVersions(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "info", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"},"Server":{"SessionManagerVersion":"2.9.11","Sessions":[{"ID":1}]}}`, + "", + 0, + ) + + diagnostics, diagnosticsErr := orchestrator.getDiagnosticsForOS(ctx, "windows") + + require.NoError(t, diagnosticsErr) + require.Equal(t, "2.9.11.0", diagnostics.ClientVersion) + require.Equal(t, "2.9.11", diagnostics.ServerVersion) +} + +func TestStatusCacheIsInstanceScoped(t *testing.T) { + t.Parallel() + + ctxOne, orchestratorOne, executorOne := newTestOrchestrator(t) + installAutoCommand( + t, + executorOne, + []string{"wslc", "version", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"}}`, + "", + 0, + ) + installAutoCommand( + t, + executorOne, + []string{"wslc", "info", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"},"Server":{"SessionManagerVersion":"2.9.11","Sessions":[{"ID":1}]}}`, + "", + 0, + ) + statusOne := orchestratorOne.CheckStatus(ctxOne, containers.CachedRuntimeStatusAllowed) + require.True(t, statusOne.IsHealthy()) + + ctxTwo, orchestratorTwo, executorTwo := newTestOrchestrator(t) + installAutoCommand( + t, + executorTwo, + []string{"wslc", "version", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"}}`, + "", + 0, + ) + installAutoCommand( + t, + executorTwo, + []string{"wslc", "info", "--format", "json"}, + "", + "session manager unavailable", + 1, + ) + statusTwo := orchestratorTwo.CheckStatus(ctxTwo, containers.CachedRuntimeStatusAllowed) + + require.True(t, statusTwo.Installed) + require.False(t, statusTwo.Running) + require.True(t, orchestratorOne.CheckStatus(ctxOne, containers.CachedRuntimeStatusAllowed).IsHealthy()) +} + +func TestBackgroundStatusUpdatesAreIdempotent(t *testing.T) { + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "version", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"}}`, + "", + 0, + ) + installAutoCommand( + t, + executor, + []string{"wslc", "info", "--format", "json"}, + `{"Client":{"Version":"2.9.11.0"},"Server":{"SessionManagerVersion":"2.9.11","Sessions":[{"ID":1}]}}`, + "", + 0, + ) + + orchestrator.EnsureBackgroundStatusUpdates(ctx) + orchestrator.EnsureBackgroundStatusUpdates(ctx) + _, waitErr := internal_testutil.WaitForCommand( + executor, + ctx, + []string{"wslc", "info", "--format", "json"}, + "", + nil, + ) + require.NoError(t, waitErr) + require.Len(t, executor.FindAll([]string{"wslc", "version", "--format", "json"}, "", nil), 1) + require.Len(t, executor.FindAll([]string{"wslc", "info", "--format", "json"}, "", nil), 1) + require.True(t, orchestrator.CheckStatus(ctx, containers.CachedRuntimeStatusAllowed).IsHealthy()) +} + +func TestWatchMethodsReturnExplicitUnsupportedErrors(t *testing.T) { + t.Parallel() + + _, orchestrator, executor := newTestOrchestrator(t) + containerSubscription, containerErr := orchestrator.WatchContainers(make(chan containers.EventMessage)) + networkSubscription, networkErr := orchestrator.WatchNetworks(make(chan containers.EventMessage)) + + require.Nil(t, containerSubscription) + require.ErrorContains(t, containerErr, "does not expose a native event stream") + require.Nil(t, networkSubscription) + require.ErrorContains(t, networkErr, "does not expose a native event stream") + require.Empty(t, executor.Executions) +} diff --git a/internal/wslc/test_helpers_test.go b/internal/wslc/test_helpers_test.go new file mode 100644 index 00000000..997c3f5e --- /dev/null +++ b/internal/wslc/test_helpers_test.go @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "io" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-logr/logr/testr" + "github.com/stretchr/testify/require" + + internal_testutil "github.com/microsoft/dcp/internal/testutil" + pkg_testutil "github.com/microsoft/dcp/pkg/testutil" +) + +func newTestOrchestrator( + t *testing.T, +) (context.Context, *WslcCliOrchestrator, *internal_testutil.TestProcessExecutor) { + t.Helper() + + ctx, cancel := pkg_testutil.GetTestContext(t, 20*time.Second) + t.Cleanup(cancel) + executor := internal_testutil.NewTestProcessExecutor(ctx) + t.Cleanup(func() { + require.NoError(t, executor.Close()) + }) + orchestrator := NewWslcCliOrchestrator(testr.New(t), executor).(*WslcCliOrchestrator) + return ctx, orchestrator, executor +} + +func installAutoCommand( + t *testing.T, + executor *internal_testutil.TestProcessExecutor, + command []string, + stdout string, + stderr string, + exitCode int32, +) { + t.Helper() + + executor.InstallAutoExecution(internal_testutil.AutoExecution{ + Condition: internal_testutil.ProcessSearchCriteria{Command: command}, + RunCommand: func(execution *internal_testutil.ProcessExecution) int32 { + if stdout != "" { + _, stdoutErr := io.WriteString(execution.Cmd.Stdout, stdout) + require.NoError(t, stdoutErr) + } + if stderr != "" { + _, stderrErr := io.WriteString(execution.Cmd.Stderr, stderr) + require.NoError(t, stderrErr) + } + return exitCode + }, + }) +} + +type testWriteSyncCloser struct { + lock sync.Mutex + buffer bytes.Buffer + closed chan struct{} + closeOnce sync.Once + syncCount atomic.Int32 +} + +func newTestWriteSyncCloser() *testWriteSyncCloser { + return &testWriteSyncCloser{closed: make(chan struct{})} +} + +func (writer *testWriteSyncCloser) Write(data []byte) (int, error) { + writer.lock.Lock() + defer writer.lock.Unlock() + return writer.buffer.Write(data) +} + +func (writer *testWriteSyncCloser) Sync() error { + writer.syncCount.Add(1) + return nil +} + +func (writer *testWriteSyncCloser) Close() error { + writer.closeOnce.Do(func() { + close(writer.closed) + }) + return nil +} + +func (writer *testWriteSyncCloser) String() string { + writer.lock.Lock() + defer writer.lock.Unlock() + return writer.buffer.String() +} diff --git a/internal/wslc/volumes.go b/internal/wslc/volumes.go new file mode 100644 index 00000000..b3e876f4 --- /dev/null +++ b/internal/wslc/volumes.go @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/microsoft/dcp/internal/containers" +) + +func (wco *WslcCliOrchestrator) CreateVolume(ctx context.Context, options containers.CreateVolumeOptions) error { + if options.Name == "" { + return fmt.Errorf("must specify a volume name") + } + + args := []string{"volume", "create"} + labelKeys := make([]string, 0, len(options.Labels)) + for key := range options.Labels { + labelKeys = append(labelKeys, key) + } + sort.Strings(labelKeys) + for _, key := range labelKeys { + if key == "" { + return fmt.Errorf("volume label key cannot be empty") + } + args = append(args, "--label", key+"="+options.Labels[key]) + } + args = append(args, options.Name) + + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "CreateVolume", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return errors.Join(runErr, normalizeCliErrors(errBuf, alreadyExistsMatch)) + } + + outputName, outputErr := parseSingleIdentifier(outBuf) + if outputErr != nil { + return outputErr + } + if outputName != options.Name { + return fmt.Errorf("wslc volume create returned name %q instead of %q", outputName, options.Name) + } + return nil +} + +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") + } + + args := append([]string{"volume", "inspect", "--format", "json"}, options.Volumes...) + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "InspectVolumes", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + + rawVolumes, decodeErr := decodeJSONArray[wslcInspectedVolume](outBuf) + if runErr != nil { + runErr = errors.Join(runErr, normalizeCliErrors(errBuf, volumeNotFoundMatch.MaxObjects(len(options.Volumes)))) + } + + inspectedVolumes := make([]containers.InspectedVolume, 0, len(rawVolumes)) + var conversionErr error + for _, rawVolume := range rawVolumes { + volumeName := strings.TrimSpace(rawVolume.Name) + if volumeName == "" { + conversionErr = errors.Join( + conversionErr, + containers.ErrUnmarshalling, + fmt.Errorf("inspected WSLC volume did not contain a name"), + ) + continue + } + inspectedVolumes = append(inspectedVolumes, containers.InspectedVolume{ + Name: volumeName, + Driver: rawVolume.Driver, + MountPoint: rawVolume.Mountpoint, + Scope: rawVolume.Scope, + Labels: rawVolume.Labels, + CreatedAt: rawVolume.CreatedAt.Time, + }) + } + + return inspectedVolumes, errors.Join( + runErr, + decodeErr, + conversionErr, + incompleteError("volumes", len(inspectedVolumes), len(options.Volumes)), + ) +} + +func (wco *WslcCliOrchestrator) ListVolumes(ctx context.Context, options containers.ListVolumesOptions) ([]containers.ListedVolume, error) { + args := []string{"volume", "list"} + for _, label := range options.Filters.LabelFilters { + filter := "label=" + label.Key + if label.Value != "" { + filter += "=" + label.Value + } + args = append(args, "--filter", filter) + } + args = append(args, "--format", "json") + + cmd := makeWslcCommand(args...) + outBuf, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "ListVolumes", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return nil, errors.Join(runErr, normalizeCliErrors(errBuf)) + } + + rawVolumes, decodeErr := decodeJSONLines[wslcListedVolume](outBuf) + listedVolumes := make([]containers.ListedVolume, 0, len(rawVolumes)) + for _, rawVolume := range rawVolumes { + volumeName := strings.TrimSpace(rawVolume.Name) + if volumeName == "" { + decodeErr = errors.Join( + decodeErr, + containers.ErrUnmarshalling, + fmt.Errorf("listed WSLC volume did not contain a name"), + ) + continue + } + listedVolumes = append(listedVolumes, containers.ListedVolume{Name: volumeName}) + } + return listedVolumes, decodeErr +} + +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") + } + + return runSequentially(ctx, "volumes", options.Volumes, func(volumeReference string) error { + args := []string{"volume", "remove"} + if options.Force { + args = append(args, "--force") + } + args = append(args, volumeReference) + + cmd := makeWslcCommand(args...) + _, errBuf, runErr := wco.runBufferedWslcCommand( + ctx, + "RemoveVolume", + cmd, + nil, + nil, + ordinaryCommandTimeout, + ) + if runErr != nil { + return errors.Join(runErr, normalizeCliErrors(errBuf, volumeNotFoundMatch, objectInUseMatch)) + } + return nil + }) +} diff --git a/internal/wslc/volumes_test.go b/internal/wslc/volumes_test.go new file mode 100644 index 00000000..dbab688e --- /dev/null +++ b/internal/wslc/volumes_test.go @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/microsoft/dcp/internal/containers" +) + +func TestVolumeLifecycleCommandsUseJsonAndPreserveRequestedNames(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "volume", "create", "--label", "owner=dcp", "volume-name"}, + "volume-name\n", + "", + 0, + ) + + createErr := orchestrator.CreateVolume(ctx, containers.CreateVolumeOptions{ + Name: "volume-name", + Labels: map[string]string{"owner": "dcp"}, + }) + require.NoError(t, createErr) + + installAutoCommand( + t, + executor, + []string{"wslc", "volume", "inspect", "--format", "json", "volume-name"}, + `[{"Name":"volume-name","Driver":"guest","Labels":{"owner":"dcp"},"Mountpoint":"/var/lib/volume","Scope":"local","CreatedAt":"2026-01-02T03:04:05Z"}]`, + "", + 0, + ) + inspected, inspectErr := orchestrator.InspectVolumes(ctx, containers.InspectVolumesOptions{ + Volumes: []string{"volume-name"}, + }) + require.NoError(t, inspectErr) + require.Len(t, inspected, 1) + require.Equal(t, "guest", inspected[0].Driver) + require.Equal(t, "dcp", inspected[0].Labels["owner"]) + require.Equal(t, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), inspected[0].CreatedAt) + + installAutoCommand( + t, + executor, + []string{"wslc", "volume", "list", "--filter", "label=owner=dcp", "--format", "json"}, + `{"Name":"volume-name","Driver":"guest"}`+"\n", + "", + 0, + ) + listed, listErr := orchestrator.ListVolumes(ctx, containers.ListVolumesOptions{ + Filters: containers.ListVolumesFilters{ + LabelFilters: []containers.LabelFilter{{Key: "owner", Value: "dcp"}}, + }, + }) + require.NoError(t, listErr) + require.Equal(t, []containers.ListedVolume{{Name: "volume-name"}}, listed) + + installAutoCommand( + t, + executor, + []string{"wslc", "volume", "remove", "--force", "volume-name"}, + "volume-name\n", + "", + 0, + ) + removed, removeErr := orchestrator.RemoveVolumes(ctx, containers.RemoveVolumesOptions{ + Volumes: []string{"volume-name"}, + Force: true, + }) + require.NoError(t, removeErr) + require.Equal(t, []string{"volume-name"}, removed) +} + +func TestInspectVolumesPreservesPartialSuccess(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand( + t, + executor, + []string{"wslc", "volume", "inspect", "--format", "json", "present", "missing"}, + `[{"Name":"present","Driver":"guest"}]`, + "Volume not found: 'missing'\n", + 1, + ) + + inspected, inspectErr := orchestrator.InspectVolumes(ctx, containers.InspectVolumesOptions{ + Volumes: []string{"present", "missing"}, + }) + + require.Equal(t, []containers.InspectedVolume{{Name: "present", Driver: "guest"}}, inspected) + require.ErrorIs(t, inspectErr, containers.ErrNotFound) + require.ErrorIs(t, inspectErr, containers.ErrIncomplete) +} + +func TestRemoveVolumesPreservesPartialSuccess(t *testing.T) { + t.Parallel() + + ctx, orchestrator, executor := newTestOrchestrator(t) + installAutoCommand(t, executor, []string{"wslc", "volume", "remove", "first"}, "first\n", "", 0) + installAutoCommand( + t, + executor, + []string{"wslc", "volume", "remove", "missing"}, + "", + "Volume not found: 'missing'\n", + 1, + ) + + removed, removeErr := orchestrator.RemoveVolumes(ctx, containers.RemoveVolumesOptions{ + Volumes: []string{"first", "missing"}, + }) + + require.Equal(t, []string{"first"}, removed) + require.True(t, errors.Is(removeErr, containers.ErrNotFound)) + require.True(t, errors.Is(removeErr, containers.ErrIncomplete)) +} diff --git a/internal/wslc/wire.go b/internal/wslc/wire.go new file mode 100644 index 00000000..57ce9448 --- /dev/null +++ b/internal/wslc/wire.go @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package wslc + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/microsoft/dcp/internal/containers" +) + +type wslcBool bool + +func (value *wslcBool) UnmarshalJSON(data []byte) error { + var boolValue bool + if boolErr := json.Unmarshal(data, &boolValue); boolErr == nil { + *value = wslcBool(boolValue) + return nil + } + + var stringValue string + if stringErr := json.Unmarshal(data, &stringValue); stringErr != nil { + return fmt.Errorf("expected a JSON boolean or boolean string: %w", stringErr) + } + + parsedValue, parseErr := strconv.ParseBool(stringValue) + if parseErr != nil { + return fmt.Errorf("parsing boolean value %q: %w", stringValue, parseErr) + } + *value = wslcBool(parsedValue) + return nil +} + +type wslcTime struct { + time.Time +} + +func (value *wslcTime) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, []byte("null")) { + value.Time = time.Time{} + return nil + } + + var stringValue string + if unmarshalErr := json.Unmarshal(data, &stringValue); unmarshalErr != nil { + return unmarshalErr + } + if stringValue == "" { + value.Time = time.Time{} + return nil + } + + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05.999999999 -0700 MST", + "2006-01-02 15:04:05 -0700 MST", + } + for _, layout := range layouts { + parsedTime, parseErr := time.Parse(layout, stringValue) + if parseErr == nil { + value.Time = parsedTime + return nil + } + } + + return fmt.Errorf("unsupported time value %q", stringValue) +} + +type wslcStringSlice []string + +func (value *wslcStringSlice) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, []byte("null")) { + *value = nil + return nil + } + + var values []string + if sliceErr := json.Unmarshal(data, &values); sliceErr == nil { + *value = values + return nil + } + + var singleValue string + if stringErr := json.Unmarshal(data, &singleValue); stringErr != nil { + return fmt.Errorf("expected a JSON string or string array: %w", stringErr) + } + if singleValue == "" { + *value = nil + } else { + *value = []string{singleValue} + } + return nil +} + +type wslcListedContainer struct { + ID string `json:"ID"` + Names string `json:"Names"` + Image string `json:"Image"` + State containers.ContainerStatus `json:"State"` + Networks string `json:"Networks"` + Labels string `json:"Labels"` +} + +type wslcInspectedContainer struct { + ID string `json:"Id"` + Name string `json:"Name"` + Created wslcTime `json:"Created"` + Config wslcInspectedContainerConfig `json:"Config"` + State wslcInspectedContainerState `json:"State"` + Ports containers.InspectedContainerPortMapping `json:"Ports"` + Mounts []wslcInspectedContainerMount `json:"Mounts"` + NetworkSettings wslcInspectedContainerNetworkSettings `json:"NetworkSettings"` +} + +type wslcInspectedContainerConfig struct { + Image string `json:"Image"` + Cmd []string `json:"Cmd"` + Entrypoint wslcStringSlice `json:"Entrypoint"` + Env []string `json:"Env"` + Labels map[string]string `json:"Labels"` + Healthcheck containers.InspectedContainerHealthcheck `json:"Healthcheck"` +} + +type wslcInspectedContainerState struct { + Status containers.ContainerStatus `json:"Status"` + Running bool `json:"Running"` + StartedAt wslcTime `json:"StartedAt"` + FinishedAt wslcTime `json:"FinishedAt"` + ExitCode int32 `json:"ExitCode"` + Error string `json:"Error"` + Health *containers.InspectedContainerHealth `json:"Health"` +} + +type wslcInspectedContainerMount struct { + Type containers.VolumeMountType `json:"Type"` + Source string `json:"Source"` + Destination string `json:"Destination"` + Name string `json:"Name"` + ReadWrite bool `json:"ReadWrite"` +} + +type wslcInspectedContainerNetworkSettings struct { + Networks map[string]wslcInspectedContainerNetwork `json:"Networks"` +} + +type wslcInspectedContainerNetwork struct { + Aliases []string `json:"Aliases"` + Gateway string `json:"Gateway"` + IPAddress string `json:"IPAddress"` + MacAddress string `json:"MacAddress"` +} + +type wslcInspectedImage struct { + ID string `json:"Id"` + RepoTags []string `json:"RepoTags"` + RepoDigests []string `json:"RepoDigests"` + Config wslcInspectedImageConfig `json:"Config"` +} + +type wslcInspectedImageConfig struct { + Labels map[string]string `json:"Labels"` +} + +type wslcListedNetwork struct { + Driver string `json:"Driver"` + ID string `json:"ID"` + IPv6 wslcBool + Internal wslcBool + Labels string `json:"Labels"` + Name string `json:"Name"` +} + +type wslcInspectedNetwork struct { + ID string `json:"Id"` + Name string `json:"Name"` + Created wslcTime `json:"Created"` + Scope string `json:"Scope"` + Driver string `json:"Driver"` + EnableIPv6 wslcBool `json:"EnableIPv6"` + IPv6 wslcBool `json:"IPv6"` + Internal wslcBool `json:"Internal"` + Attachable wslcBool `json:"Attachable"` + Ingress wslcBool `json:"Ingress"` + IPAM wslcInspectedNetworkIPAM `json:"IPAM"` + Labels map[string]string `json:"Labels"` + Containers map[string]wslcInspectedNetworkContainer `json:"Containers"` +} + +type wslcInspectedNetworkContainer struct { + Name string `json:"Name"` +} + +type wslcInspectedNetworkIPAM struct { + Config []wslcInspectedNetworkIPAMConfig `json:"Config"` +} + +type wslcInspectedNetworkIPAMConfig struct { + Subnet string `json:"Subnet"` + Gateway string `json:"Gateway"` +} + +type wslcListedVolume struct { + Name string `json:"Name"` +} + +type wslcInspectedVolume struct { + Name string `json:"Name"` + Driver string `json:"Driver"` + Labels map[string]string `json:"Labels"` + Mountpoint string `json:"Mountpoint"` + Scope string `json:"Scope"` + CreatedAt wslcTime `json:"CreatedAt"` +} + +func decodeJSONLines[T any](buffer *bytes.Buffer) ([]T, error) { + if buffer == nil { + return nil, fmt.Errorf("wslc command returned no output buffer") + } + + results := make([]T, 0) + var decodeErrors error + scanner := bufio.NewScanner(bytes.NewReader(buffer.Bytes())) + scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + + var result T + if unmarshalErr := json.Unmarshal(line, &result); unmarshalErr != nil { + decodeErrors = errors.Join( + decodeErrors, + containers.ErrUnmarshalling, + fmt.Errorf("decoding WSLC JSON line %d: %w", lineNumber, unmarshalErr), + ) + continue + } + results = append(results, result) + } + if scanErr := scanner.Err(); scanErr != nil { + decodeErrors = errors.Join(decodeErrors, containers.ErrUnmarshalling, scanErr) + } + + return results, decodeErrors +} + +func decodeJSONArray[T any](buffer *bytes.Buffer) ([]T, error) { + if buffer == nil { + return nil, fmt.Errorf("wslc command returned no output buffer") + } + + data := bytes.TrimSpace(buffer.Bytes()) + if len(data) == 0 { + return []T{}, nil + } + + var rawObjects []json.RawMessage + if arrayErr := json.Unmarshal(data, &rawObjects); arrayErr != nil { + return nil, errors.Join( + containers.ErrUnmarshalling, + fmt.Errorf("decoding WSLC JSON array: %w", arrayErr), + ) + } + + results := make([]T, 0, len(rawObjects)) + var decodeErrors error + for index, rawObject := range rawObjects { + var result T + if objectErr := json.Unmarshal(rawObject, &result); objectErr != nil { + decodeErrors = errors.Join( + decodeErrors, + containers.ErrUnmarshalling, + fmt.Errorf("decoding WSLC JSON object %d: %w", index, objectErr), + ) + continue + } + results = append(results, result) + } + + return results, decodeErrors +} + +func splitCommaSeparated(value string) []string { + parts := strings.Split(value, ",") + results := make([]string, 0, len(parts)) + for _, part := range parts { + trimmedPart := strings.TrimSpace(part) + if trimmedPart != "" { + results = append(results, trimmedPart) + } + } + return results +} + +func imageDigest(repoDigests []string) string { + for _, repoDigest := range repoDigests { + trimmedDigest := strings.TrimSpace(repoDigest) + if trimmedDigest == "" { + continue + } + if _, digest, found := strings.Cut(trimmedDigest, "@"); found { + return digest + } + return trimmedDigest + } + return "" +} diff --git a/pkg/testutil/context.go b/pkg/testutil/context.go index 8c83b435..8a94c13e 100644 --- a/pkg/testutil/context.go +++ b/pkg/testutil/context.go @@ -27,7 +27,7 @@ const ( // verify behavior. DCP_TEST_ENABLE_ADVANCED_CERTIFICATES = "DCP_TEST_ENABLE_ADVANCED_CERTIFICATES" - // Set to true to enable tests that require real container orchestrator (Docker or Podman). + // Set to true to enable tests that require a real container orchestrator (Docker, Podman, or WSLC). DCP_TEST_ENABLE_TRUE_CONTAINER_ORCHESTRATOR = "DCP_TEST_ENABLE_TRUE_CONTAINER_ORCHESTRATOR" // Used by VS Code to disable skipping tests when they are run with the debugger. diff --git a/test/containers/container_test.go b/test/containers/container_test.go index d0e1be28..517d7c03 100644 --- a/test/containers/container_test.go +++ b/test/containers/container_test.go @@ -86,6 +86,77 @@ func TestContainerLifecycleMethods(t *testing.T) { }) } +func TestCreateContainerLabelsUseLastValue(t *testing.T) { + t.Parallel() + + forEachHealthyRuntime(t, func(t *testing.T, ctx context.Context, runtime containertest.Runtime) { + tracker := containertest.NewResourceTracker(t, runtime) + containerName := containertest.UniqueName(t, "duplicate-container-label") + require.NoError(t, tracker.TrackContainer(containerName)) + const labelKey = "com.microsoft.developer.dcp.duplicate-label-test" + options := longRunningContainerOptions(containerName, ensureTestImage(t, ctx, runtime), tracker.Labels()) + options.Labels = append(options.Labels, + containers.Label{Key: labelKey, Value: "first"}, + containers.Label{Key: labelKey, Value: "last"}, + ) + + containerID, createErr := runtime.Orchestrator.CreateContainer(ctx, options) + require.NoError(t, createErr) + inspected, inspectErr := runtime.Orchestrator.InspectContainers(ctx, containers.InspectContainersOptions{ + Containers: []string{containerID}, + }) + require.NoError(t, inspectErr) + require.Len(t, inspected, 1) + require.Equal(t, "last", inspected[0].Labels[labelKey]) + }) +} + +func TestInspectContainersPreservesPartialResults(t *testing.T) { + t.Parallel() + + forEachHealthyRuntime(t, func(t *testing.T, ctx context.Context, runtime containertest.Runtime) { + tracker := containertest.NewResourceTracker(t, runtime) + containerName, containerID := runLongLivedContainer(t, ctx, runtime, tracker, "partial-inspect") + missingName := containertest.UniqueName(t, "missing-inspect") + + inspected, inspectErr := runtime.Orchestrator.InspectContainers(ctx, containers.InspectContainersOptions{ + Containers: []string{containerName, missingName}, + }) + require.ErrorIs(t, inspectErr, containers.ErrNotFound) + require.Len(t, inspected, 1) + require.Equal(t, containerID, inspected[0].Id) + require.Equal(t, containerName, inspected[0].Name) + }) +} + +func TestStartContainersPreservesPartialResults(t *testing.T) { + t.Parallel() + + forEachHealthyRuntime(t, func(t *testing.T, ctx context.Context, runtime containertest.Runtime) { + tracker := containertest.NewResourceTracker(t, runtime) + image := ensureTestImage(t, ctx, runtime) + containerNames := []string{ + containertest.UniqueName(t, "partial-start-first"), + containertest.UniqueName(t, "partial-start-second"), + } + for _, containerName := range containerNames { + require.NoError(t, tracker.TrackContainer(containerName)) + _, createErr := runtime.Orchestrator.CreateContainer(ctx, longRunningContainerOptions(containerName, image, tracker.Labels())) + require.NoError(t, createErr) + } + missingName := containertest.UniqueName(t, "missing-start") + + started, startErr := runtime.Orchestrator.StartContainers(ctx, containers.StartContainersOptions{ + Containers: []string{containerNames[0], missingName, containerNames[1]}, + }) + require.ErrorIs(t, startErr, containers.ErrNotFound) + require.ElementsMatch(t, containerNames, started) + for _, containerName := range containerNames { + waitForContainerStatus(t, ctx, runtime.Orchestrator, containerName, containers.ContainerStatusRunning) + } + }) +} + func TestRunAndExecContainerMethods(t *testing.T) { t.Parallel() @@ -239,6 +310,7 @@ func TestWatchContainersMethod(t *testing.T) { t.Parallel() forEachHealthyRuntime(t, func(t *testing.T, ctx context.Context, runtime containertest.Runtime) { + containertest.SkipIfNativeRuntimeEventsUnavailable(t, runtime) tracker := containertest.NewResourceTracker(t, runtime) events := concurrency.NewUnboundedChan[containers.EventMessage](ctx) diff --git a/test/containers/network_test.go b/test/containers/network_test.go index 89df3c14..964a513d 100644 --- a/test/containers/network_test.go +++ b/test/containers/network_test.go @@ -106,10 +106,79 @@ func TestNetworkMethods(t *testing.T) { }) } +func TestContainerCreationAcceptsNetworkIDs(t *testing.T) { + t.Parallel() + + forEachHealthyRuntime(t, func(t *testing.T, ctx context.Context, runtime containertest.Runtime) { + tracker := containertest.NewResourceTracker(t, runtime) + image := ensureTestImage(t, ctx, runtime) + type testNetwork struct { + id string + name string + alias string + } + networks := make([]testNetwork, 0, 2) + for index := 0; index < 2; index++ { + networkName := containertest.UniqueName(t, fmt.Sprintf("creation-network-%d", index)) + require.NoError(t, tracker.TrackNetwork(networkName)) + networkID, createNetworkErr := runtime.Orchestrator.CreateNetwork(ctx, containers.CreateNetworkOptions{ + Name: networkName, Labels: tracker.MapLabels(), + }) + require.NoError(t, createNetworkErr) + networks = append(networks, testNetwork{ + id: networkID, name: networkName, + alias: containertest.UniqueName(t, fmt.Sprintf("creation-alias-%d", index)), + }) + } + + for _, operation := range []string{"create", "run"} { + t.Run(operation, func(t *testing.T) { + containerName := containertest.UniqueName(t, "network-id-container") + require.NoError(t, tracker.TrackContainer(containerName)) + options := longRunningContainerOptions(containerName, image, tracker.Labels()) + for _, network := range networks { + options.Networks = append(options.Networks, containers.CreateContainerNetworkOptions{ + Name: network.id, Aliases: []string{network.alias}, + }) + } + + var containerID string + var createErr error + if operation == "run" { + containerID, createErr = runtime.Orchestrator.RunContainer(ctx, containers.RunContainerOptions{CreateContainerOptions: options}) + } else { + containerID, createErr = runtime.Orchestrator.CreateContainer(ctx, options) + } + require.NoError(t, createErr) + if operation == "create" { + _, startErr := runtime.Orchestrator.StartContainers(ctx, containers.StartContainersOptions{ + Containers: []string{containerID}, + }) + require.NoError(t, startErr) + } + waitForContainerStatus(t, ctx, runtime.Orchestrator, containerID, containers.ContainerStatusRunning) + inspected, inspectErr := runtime.Orchestrator.InspectContainers(ctx, containers.InspectContainersOptions{ + Containers: []string{containerID}, + }) + require.NoError(t, inspectErr) + require.Len(t, inspected, 1) + for _, network := range networks { + networkIndex := std_slices.IndexFunc(inspected[0].Networks, func(attached containers.InspectedContainerNetwork) bool { + return attached.Id == network.id && attached.Name == network.name + }) + require.NotEqual(t, -1, networkIndex) + require.Contains(t, inspected[0].Networks[networkIndex].Aliases, network.alias) + } + }) + } + }) +} + func TestWatchNetworksMethod(t *testing.T) { t.Parallel() forEachHealthyRuntime(t, func(t *testing.T, ctx context.Context, runtime containertest.Runtime) { + containertest.SkipIfNativeRuntimeEventsUnavailable(t, runtime) tracker := containertest.NewResourceTracker(t, runtime) events := concurrency.NewUnboundedChan[containers.EventMessage](ctx) diff --git a/test/integration/container_controller_terminal_test.go b/test/integration/container_controller_terminal_test.go index 21e8235f..660b5180 100644 --- a/test/integration/container_controller_terminal_test.go +++ b/test/integration/container_controller_terminal_test.go @@ -24,6 +24,7 @@ import ( apiv1 "github.com/microsoft/dcp/api/v1" "github.com/microsoft/dcp/internal/containers" "github.com/microsoft/dcp/internal/termpty" + "github.com/microsoft/dcp/internal/testutil/containertest" ctrl_testutil "github.com/microsoft/dcp/internal/testutil/ctrlutil" usvc_io "github.com/microsoft/dcp/pkg/io" usvc_random "github.com/microsoft/dcp/pkg/randdata" @@ -391,23 +392,36 @@ func TestContainerTerminalAttachFailure(t *testing.T) { // ==================================================================================== // TestContainerTerminalEndToEndWithRealOrchestrator exercises the full container -// terminal path through Docker/Podman attach to a real container running an -// interactive busybox shell. It is gated by SkipIfTrueContainerOrchestratorNotEnabled -// (i.e., DCP_TEST_ENABLE_TRUE_CONTAINER_ORCHESTRATOR=true). +// terminal path through each supported runtime using an interactive busybox shell. func TestContainerTerminalEndToEndWithRealOrchestrator(t *testing.T) { - testutil.SkipIfTrueContainerOrchestratorNotEnabled(t) - t.Parallel() const testTimeout = 3 * time.Minute - ctx, cancel := testutil.GetTestContext(t, testTimeout) + testCtx, testCancel := testutil.GetTestContext(t, testTimeout) + t.Cleanup(testCancel) + containertest.ForEachHealthyRuntime(t, testCtx, testContainerTerminalWithRealOrchestrator) +} + +func testContainerTerminalWithRealOrchestrator( + t *testing.T, + runtimeCtx context.Context, + runtime containertest.Runtime, +) { + ctx, cancel := context.WithCancel(runtimeCtx) defer cancel() + tracker := containertest.NewResourceTracker(t, runtime) + containerName := containertest.UniqueName(t, "container-terminal") + require.NoError(t, tracker.TrackContainer(containerName)) - serverInfo, teInfo, startupErr := StartAdvancedTestEnvironment( + serverInfo, teInfo, startupErr := StartAdvancedTestEnvironmentWithOptions( ctx, ContainerController, - t.Name(), - NoSeparateWorkingDir, + containertest.UniqueName(t, "terminal-environment"), + t.TempDir(), + AdvancedTestEnvironmentOptions{ + ApiServerFlags: ctrl_testutil.ApiServerUseTrueContainerOrchestrator, + ContainerOrchestrator: runtime.Orchestrator, + }, ) require.NoError(t, startupErr, "failed to start advanced test environment") defer teInfo.ProcessExecutor.Dispose() @@ -415,15 +429,14 @@ func TestContainerTerminalEndToEndWithRealOrchestrator(t *testing.T) { apiClient := serverInfo.Client socketPath := pickContainerTerminalSocketPath(t) - const ctrName = "test-ctr-term-e2e" ctr := &apiv1.Container{ ObjectMeta: metav1.ObjectMeta{ - Name: ctrName, + Name: containerName, Namespace: metav1.NamespaceNone, }, Spec: apiv1.ContainerSpec{ - ContainerName: ctrName, + ContainerName: containerName, Image: "busybox:latest", Command: "sh", Args: []string{"-i"}, @@ -434,6 +447,9 @@ func TestContainerTerminalEndToEndWithRealOrchestrator(t *testing.T) { }, }, } + for _, label := range tracker.Labels() { + ctr.Spec.Labels = append(ctr.Spec.Labels, apiv1.ContainerLabel{Key: label.Key, Value: label.Value}) + } require.NoError(t, apiClient.Create(ctx, ctr), "create Container") t.Cleanup(func() { _ = apiClient.Delete(context.Background(), ctr) }) diff --git a/test/integration/container_network_tunnel_proxy_test.go b/test/integration/container_network_tunnel_proxy_test.go index 5b858a09..63903067 100644 --- a/test/integration/container_network_tunnel_proxy_test.go +++ b/test/integration/container_network_tunnel_proxy_test.go @@ -30,6 +30,7 @@ import ( "github.com/microsoft/dcp/controllers" "github.com/microsoft/dcp/internal/apiserver" "github.com/microsoft/dcp/internal/containers" + container_flags "github.com/microsoft/dcp/internal/containers/flags" "github.com/microsoft/dcp/internal/dcppaths" "github.com/microsoft/dcp/internal/dcptun" dcptunproto "github.com/microsoft/dcp/internal/dcptun/proto" @@ -1169,13 +1170,13 @@ func TestTunnelProxyClientUnexpectedExit(t *testing.T) { _ = waitAllTunnelsInState(t, ctx, serverInfo.Client, tunnelProxy.NamespacedName(), len(tunnelProxy.Spec.Tunnels), apiv1.TunnelStateFailed) } -// Verifies that a ContainerNetworkTunnelProxy really works with real container orchestrator (Docker or Podman). +// Verifies that a ContainerNetworkTunnelProxy works with each supported real container orchestrator. // This is an advanced test that is not included in routine test runs. // Requires DCP_TEST_ENABLE_TRUE_CONTAINER_ORCHESTRATOR environment variable to be set to "true". func TestTunnelProxyWithRealOrchestrator(t *testing.T) { t.Parallel() - const testTimeout = 6 * time.Minute + const testTimeout = 3 * time.Minute testCtx, testCancel := testutil.GetTestContext(t, testTimeout) t.Cleanup(testCancel) @@ -1187,6 +1188,7 @@ func testTunnelProxyWithRealOrchestrator( runtimeCtx context.Context, runtime containertest.Runtime, ) { + containertest.SkipIfNativeRuntimeEventsUnavailable(t, runtime) const parrotTimeout = 3 * time.Minute ctx, cancel := context.WithCancel(runtimeCtx) @@ -1343,10 +1345,12 @@ func testTunnelProxyWithRealOrchestrator( Name: network.Name, }, }, - // Enable ability to do pings etc. from within container, for debugging purposes. - RunArgs: []string{"--cap-add=NET_RAW"}, }, } + if runtime.Name != string(container_flags.WslcRuntime) { + // Optional ping/debug access is not exposed by the WSLC CLI. + clientCtr.Spec.RunArgs = []string{"--cap-add=NET_RAW"} + } t.Logf("Creating parrot client Container '%s'...", clientCtr.ObjectMeta.Name) err = serverInfo.Client.Create(ctx, clientCtr) diff --git a/test/integration/v2_physical_container_network_runtime_test.go b/test/integration/v2_physical_container_network_runtime_test.go new file mode 100644 index 00000000..991026a4 --- /dev/null +++ b/test/integration/v2_physical_container_network_runtime_test.go @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package integration_test + +import ( + "context" + std_slices "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + + apiv2 "github.com/microsoft/dcp/api/v2" + "github.com/microsoft/dcp/internal/containers" + internal_testutil "github.com/microsoft/dcp/internal/testutil" + "github.com/microsoft/dcp/internal/testutil/containertest" + ctrl_testutil "github.com/microsoft/dcp/internal/testutil/ctrlutil" + "github.com/microsoft/dcp/pkg/testutil" +) + +func TestPhysicalContainerNetworkRemovesStoppedAttachmentsWithRealOrchestrator(t *testing.T) { + t.Parallel() + + testCtx, testCancel := testutil.GetTestContext(t, 3*time.Minute) + t.Cleanup(testCancel) + containertest.ForEachHealthyRuntime(t, testCtx, func(t *testing.T, runtimeCtx context.Context, runtime containertest.Runtime) { + ctx, cancel := context.WithCancel(runtimeCtx) + defer cancel() + tracker := containertest.NewResourceTracker(t, runtime) + networkName := containertest.UniqueName(t, "physical-network-cleanup") + containerName := containertest.UniqueName(t, "physical-network-stopped") + require.NoError(t, tracker.TrackNetwork(networkName)) + require.NoError(t, tracker.TrackContainer(containerName)) + + serverInfo, environmentInfo, startupErr := StartAdvancedTestEnvironmentWithOptions( + ctx, + NamespaceController|PhysicalContainerNetworkController, + containertest.UniqueName(t, "physical-network-environment"), + t.TempDir(), + AdvancedTestEnvironmentOptions{ + ApiServerFlags: ctrl_testutil.ApiServerUseTrueContainerOrchestrator, + ContainerOrchestrator: runtime.Orchestrator, + }, + ) + require.NoError(t, startupErr) + defer environmentInfo.ProcessExecutor.Dispose() + defer shutdownAdvancedTestEnvironment(t, ctx, cancel, serverInfo) + + namespace := &apiv2.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: containertest.UniqueName(t, "physical-network-namespace"), + }} + require.NoError(t, serverInfo.Client.Create(ctx, namespace)) + waitObjectAssumesStateEx(t, ctx, serverInfo.Client, types.NamespacedName{Name: namespace.Name}, func(updated *apiv2.Namespace) (bool, error) { + return updated.Status.Phase == apiv2.NamespacePhaseActive, nil + }) + networkResource := &apiv2.PhysicalContainerNetwork{ + ObjectMeta: metav1.ObjectMeta{Name: "network", Namespace: namespace.Name}, + Spec: apiv2.PhysicalContainerNetworkSpec{ + Network: &apiv2.PhysicalContainerNetworkConfig{ + NetworkName: networkName, + Labels: tracker.Labels(), + }, + }, + } + require.NoError(t, serverInfo.Client.Create(ctx, networkResource)) + readyNetwork := waitPhysicalContainerNetworkPhaseEx( + t, ctx, serverInfo.Client, networkResource.NamespacedName(), apiv2.PhysicalContainerNetworkPhaseReady, + ) + networkID := readyNetwork.Status.NetworkID + + imageID, pullErr := runtime.Orchestrator.PullImage(ctx, containers.PullImageOptions{ + Image: "mcr.microsoft.com/azurelinux/distroless/minimal:3.0.20260809", + }) + require.NoError(t, pullErr) + + const probePath = "/dcp-network-probe" + containerID, createContainerErr := runtime.Orchestrator.CreateContainer(ctx, containers.CreateContainerOptions{ + Name: containerName, Image: imageID, + Entrypoint: probePath, Command: []string{"exit"}, + Labels: tracker.Labels(), PullPolicy: containers.PullPolicyNever, + Networks: []containers.CreateContainerNetworkOptions{{Name: networkName}}, + }) + require.NoError(t, createContainerErr) + + probeBinary, probePathErr := internal_testutil.GetTestContainerToolPath("container_probe_c") + require.NoError(t, probePathErr) + require.NoError(t, runtime.Orchestrator.CreateFiles(ctx, containers.CreateFilesOptions{ + Container: containerID, Destination: "/", + Entries: []containers.FileSystemEntry{{ + Name: "dcp-network-probe", Source: probeBinary, Mode: 0755, + }}, + })) + _, startErr := runtime.Orchestrator.StartContainers(ctx, containers.StartContainersOptions{ + Containers: []string{containerID}, + }) + require.NoError(t, startErr) + + waitErr := wait.PollUntilContextCancel(ctx, 200*time.Millisecond, true, func(pollCtx context.Context) (bool, error) { + inspected, inspectErr := runtime.Orchestrator.InspectContainers(pollCtx, containers.InspectContainersOptions{ + Containers: []string{containerID}, + }) + if inspectErr != nil { + return false, inspectErr + } + return len(inspected) == 1 && inspected[0].Status == containers.ContainerStatusExited, nil + }) + require.NoError(t, waitErr) + + require.NoError(t, serverInfo.Client.Delete(ctx, networkResource)) + ctrl_testutil.WaitObjectDeleted[apiv2.PhysicalContainerNetwork](t, ctx, serverInfo.Client, networkResource) + + remainingNetworks, inspectNetworkErr := runtime.Orchestrator.InspectNetworks(ctx, containers.InspectNetworksOptions{ + Networks: []string{networkID}, + }) + require.Empty(t, remainingNetworks) + require.ErrorIs(t, inspectNetworkErr, containers.ErrNotFound) + remainingContainers, inspectContainerErr := runtime.Orchestrator.InspectContainers(ctx, containers.InspectContainersOptions{ + Containers: []string{containerID}, + }) + require.NoError(t, inspectContainerErr) + require.Len(t, remainingContainers, 1) + require.False(t, std_slices.ContainsFunc(remainingContainers[0].Networks, func(network containers.InspectedContainerNetwork) bool { + return network.Id == networkID || network.Name == networkName + })) + }) +}