Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
19 changes: 14 additions & 5 deletions controllers/network_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand All @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down
127 changes: 127 additions & 0 deletions controllers/network_controller_watch_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
2 changes: 1 addition & 1 deletion internal/containers/container_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions internal/containers/create_files.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading