From f3d6df658233a759c853d7c129f562ce6f8c92bc Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:47:04 -0700 Subject: [PATCH] Use v2 physical resources for tunnel proxies (#250) * Use physical resources for tunnel proxies Build and run the tunnel client proxy through v2 PhysicalContainerImage and PhysicalContainer resources in the shared v1 compatibility namespace. Add digest-addressed file and inline archive build contexts that Docker and Podman stream through stdin. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Address tunnel physical resource review feedback Add physical container lifecycle monitoring, consolidate image build command handling, tighten archive validation and cleanup behavior, and restore lifecycle regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Reject archive root Dockerfile paths Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Validate image build contexts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Keep tunnel image cleanup best effort Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Refine tunnel physical resource orchestration Move tunnel image preparation fully behind v2 physical resources, reuse existing images, and simplify tunnel state coordination. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Clean up unused tunnel image archives Remove build-context archives created by losing shared-image creation attempts while preserving any archive referenced by the persisted resource. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Track physical image build inputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Separate image pull and build policies Apply pull policies consistently to source images and declared build bases while making build output reuse independently configurable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Move tunnel cleanup coverage to integration tests Exercise build-context race cleanup and preservation through the tunnel controller integration environment instead of a white-box controller test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Use logical tunnel build context digest Derive the archive digest from stable Dockerfile and tunnel binary inputs while retaining the raw archive SHA256 solely for integrity verification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Generalize physical image context digests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Regenerate OpenAPI after dependency update Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Return image inspection snapshots Clone mutable image metadata returned by the test container orchestrator so callers cannot race with or mutate orchestrator-owned state. Add regression coverage for labels and tags. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Track effective physical image build inputs Keep the build provenance fingerprint internal while including inherited build arguments, environment secrets, and file secret contents. Verify reuse and rebuild behavior through the physical image integration suite. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Harden tunnel physical resource startup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Use context-aware lock for image preparation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Scope physical controllers to tunnel tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Avoid aliasing test image tags Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Clarify archive path semantics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Reconcile tunnel physical cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Add physical container network connections Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Keep physical controller tests in integration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Compare physical network connection specs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Guard missing failed tunnel proxy data Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Document failed tunnel proxy invariant Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Release image lock on shutdown Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Require exact physical container network IDs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 * Improve tunnel proxy startup diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc6d6a11-725e-4f6b-bed6-d6337b5e5ea5 --- api/v2/common_types.go | 3 + api/v2/container_types.go | 54 +- api/v2/groupversion_info.go | 1 + api/v2/physical_container_image_types.go | 89 +- api/v2/physical_container_image_types_test.go | 296 +++++- ...ical_container_network_connection_types.go | 166 ++++ ...container_network_connection_types_test.go | 113 +++ api/v2/physical_container_network_types.go | 4 + api/v2/physical_container_types.go | 15 +- api/v2/zz_generated.deepcopy.go | 108 +++ api/v2/zz_generated.model_name.go | 20 + controllers/container_controller.go | 2 +- ...ntainer_network_tunnel_proxy_controller.go | 798 +++++++++------ .../container_network_tunnel_proxy_data.go | 9 + controllers/controller_common.go | 78 ++ controllers/controller_common_test.go | 44 + controllers/namespace_controller.go | 42 +- controllers/namespace_controller_test.go | 12 +- controllers/physical_container_controller.go | 297 +++++- .../physical_container_controller_test.go | 112 --- controllers/physical_container_data.go | 58 ++ .../physical_container_image_controller.go | 433 ++++++++- controllers/physical_container_image_data.go | 5 + .../physical_container_network_controller.go | 288 +++++- .../physical_container_network_data.go | 19 + internal/containers/build_context_archive.go | 52 + .../containers/build_context_archive_test.go | 85 ++ internal/containers/image_build.go | 124 +++ internal/containers/image_build_test.go | 170 ++++ internal/containers/image_layers.go | 20 +- internal/containers/image_layers_test.go | 10 +- internal/containers/image_orchestrator.go | 12 + internal/dcpctrl/commands/run_controllers.go | 2 +- internal/dcptun/image_builder.go | 479 +++------ internal/dcptun/image_builder_test.go | 197 +--- internal/dcptun/mr_image_builds.go | 138 --- internal/docker/cli_orchestrator.go | 95 +- internal/podman/cli_orchestrator.go | 92 +- internal/resourcecleanup/resources.go | 6 + internal/resourcecleanup/resources_test.go | 9 +- .../ctrlutil/test_container_orchestrator.go | 75 +- .../test_container_orchestrator_test.go | 72 ++ pkg/generated/openapi/zz_generated.openapi.go | 562 +++++++---- test/integration/advanced_test_env.go | 12 +- .../container_network_tunnel_proxy_test.go | 255 ++++- test/integration/standard_test_env.go | 19 +- .../v2_physical_container_controller_test.go | 189 +++- ...hysical_container_image_controller_test.go | 906 ++++++++++++++++++ ...sical_container_network_connection_test.go | 200 ++++ ...sical_container_network_controller_test.go | 2 +- 50 files changed, 5333 insertions(+), 1516 deletions(-) create mode 100644 api/v2/physical_container_network_connection_types.go create mode 100644 api/v2/physical_container_network_connection_types_test.go delete mode 100644 controllers/physical_container_controller_test.go create mode 100644 internal/containers/build_context_archive.go create mode 100644 internal/containers/build_context_archive_test.go create mode 100644 internal/containers/image_build.go create mode 100644 internal/containers/image_build_test.go delete mode 100644 internal/dcptun/mr_image_builds.go create mode 100644 test/integration/v2_physical_container_network_connection_test.go diff --git a/api/v2/common_types.go b/api/v2/common_types.go index 2e597e1e..2266abab 100644 --- a/api/v2/common_types.go +++ b/api/v2/common_types.go @@ -44,6 +44,9 @@ const ( // PhysicalResourceReasonNamespaceLookupFailed indicates that the resource's namespace could not be read. PhysicalResourceReasonNamespaceLookupFailed ConditionReason = "NamespaceLookupFailed" + // PhysicalResourceReasonContainerRuntimeUnhealthy indicates that reconciliation is blocked because the container runtime is not healthy. + PhysicalResourceReasonContainerRuntimeUnhealthy ConditionReason = "ContainerRuntimeUnhealthy" + // PhysicalResourceReasonOperationStateInvalid indicates that controller-owned operation state is invalid. PhysicalResourceReasonOperationStateInvalid ConditionReason = "OperationStateInvalid" ) diff --git a/api/v2/container_types.go b/api/v2/container_types.go index d9ef5396..f4992b45 100644 --- a/api/v2/container_types.go +++ b/api/v2/container_types.go @@ -127,10 +127,10 @@ func ValidateContainerPorts(ports []ContainerPort, portsPath *field.Path) field. return errorList } -// ContainerNetworkConnectionConfig describes a network to attach to when creating a container. +// ContainerNetworkConnectionConfig describes a PhysicalContainerNetwork to attach to a container. // +k8s:openapi-gen=true type ContainerNetworkConnectionConfig struct { - // Name of the network to connect to. + // Name of the PhysicalContainerNetwork to connect to in the container's namespace. Name string `json:"name"` // Aliases of the container on the network. @@ -187,7 +187,20 @@ type ContainerBuildSecret struct { // +k8s:openapi-gen=true type ContainerBuildContext struct { // The path to the directory to be used as the root of the build context. - Context string `json:"context"` + // Exactly one of context or contextArchive must be set. + // +optional + Context string `json:"context,omitempty"` + + // A tar archive to stream to the image builder as the build context. + // Exactly one of context or contextArchive must be set. + // +optional + ContextArchive *ContainerBuildContextArchive `json:"contextArchive,omitempty"` + + // An opaque identifier for the logical contents of the build context. PhysicalContainerImage + // uses this value to determine whether an existing build output can be reused. If omitted, + // the build context is treated as changed and the output is rebuilt. + // +optional + Digest string `json:"digest,omitempty"` // The path to a Dockerfile to use for the build. // +optional @@ -222,6 +235,26 @@ type ContainerBuildContext struct { // Optional target platform for the build (e.g. "linux/amd64"). // +optional Platform string `json:"platform,omitempty"` + + // BaseImages identifies image references used by the build. PhysicalContainerImage applies + // its pull policy to these images and includes their resolved identities when determining + // whether an existing build output is current. + // +listType=set + // +optional + BaseImages []string `json:"baseImages,omitempty"` +} + +// ContainerBuildContextArchive describes a tar archive containing an image build context. +// +k8s:openapi-gen=true +type ContainerBuildContextArchive struct { + // Path to a tar file on the host filesystem. Mutually exclusive with RawContents. + Source string `json:"source,omitempty"` + + // SHA256 hash of the tar file referenced by Source. Required when Source is set. + SHA256 string `json:"sha256,omitempty"` + + // Base64-encoded tar file contents. Mutually exclusive with Source. + RawContents string `json:"rawContents,omitempty"` } type ImagePullPolicy string @@ -230,6 +263,11 @@ const ( // Always pull the container image. PullPolicyAlways ImagePullPolicy = "always" + // Attempt to pull a source image, but use an existing local image if pulling fails. + // For builds, declared base images are resolved this way and their immutable identities + // participate in deciding whether an existing build output can be reused. + PullPolicyBestEffort ImagePullPolicy = "bestEffort" + // Pull the container image only if it is not present. PullPolicyMissing ImagePullPolicy = "missing" @@ -237,6 +275,16 @@ const ( PullPolicyNever ImagePullPolicy = "never" ) +type ImageBuildPolicy string + +const ( + // Reuse an existing image when its material build inputs match. + BuildPolicyIfNeeded ImageBuildPolicy = "ifNeeded" + + // Always build the container image. + BuildPolicyAlways ImageBuildPolicy = "always" +) + type FileSystemEntryType string const ( diff --git a/api/v2/groupversion_info.go b/api/v2/groupversion_info.go index 99b485a1..92d1c0f9 100644 --- a/api/v2/groupversion_info.go +++ b/api/v2/groupversion_info.go @@ -35,6 +35,7 @@ var ( &PhysicalContainerImage{}, &PhysicalContainer{}, &PhysicalContainerNetwork{}, + &PhysicalContainerNetworkConnection{}, &PhysicalContainerVolume{}, &PhysicalProcess{}, } diff --git a/api/v2/physical_container_image_types.go b/api/v2/physical_container_image_types.go index 8b2da751..5701113c 100644 --- a/api/v2/physical_container_image_types.go +++ b/api/v2/physical_container_image_types.go @@ -7,7 +7,12 @@ package v2 import ( "context" + "encoding/base64" + "fmt" + "io/fs" + "path" "reflect" + "regexp" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -23,6 +28,16 @@ import ( "github.com/microsoft/dcp/pkg/commonapi" ) +var validSHA256HexRegexp = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) + +// isArchiveRelativePath reports whether archivePath names a non-root entry using +// slash-separated archive path semantics. Backslashes are ordinary path element +// characters, not separators. The path must be relative and cannot escape the archive root. +func isArchiveRelativePath(archivePath string) bool { + cleaned := path.Clean(archivePath) + return cleaned != "." && fs.ValidPath(cleaned) +} + // PhysicalContainerImagePhase describes the lifecycle phase of a PhysicalContainerImage. type PhysicalContainerImagePhase PhysicalResourcePhase @@ -94,13 +109,19 @@ type PhysicalContainerImageConfig struct { // Build describes how to build the image locally. Build *ContainerBuildContext `json:"build,omitempty"` - // PullPolicy controls source image pulling. If omitted, missing is used. - // Never is not supported for image builds. + // PullPolicy controls pulling the source image or declared build base images. Best-effort + // attempts to pull but uses an existing local image when pulling fails. If omitted, missing + // is used. PullPolicy ImagePullPolicy `json:"pullPolicy,omitempty"` + // BuildPolicy controls whether a matching existing build output can be reused. If omitted, + // ifNeeded is used. Only supported when build is set. + BuildPolicy ImageBuildPolicy `json:"buildPolicy,omitempty"` + // PullRetryLimit is how many times a failed source image pull is retried, with exponential // backoff between attempts. Set to zero to fail on the first error. If omitted, a small // default number of retries is used to absorb transient registry and network failures. + // Pulls are deferred while the container runtime is unhealthy, without consuming this limit. // +kubebuilder:validation:Minimum=0 // +optional PullRetryLimit *int32 `json:"pullRetryLimit,omitempty"` @@ -225,24 +246,33 @@ func (pci *PhysicalContainerImage) Validate(ctx context.Context) field.ErrorList } switch image.PullPolicy { - case "", PullPolicyAlways, PullPolicyMissing, PullPolicyNever: + case "", PullPolicyAlways, PullPolicyBestEffort, PullPolicyMissing, PullPolicyNever: default: errorList = append(errorList, field.NotSupported(imagePath.Child("pullPolicy"), image.PullPolicy, []string{ string(PullPolicyAlways), + string(PullPolicyBestEffort), string(PullPolicyMissing), string(PullPolicyNever), })) } + switch image.BuildPolicy { + case "", BuildPolicyAlways, BuildPolicyIfNeeded: + default: + errorList = append(errorList, field.NotSupported(imagePath.Child("buildPolicy"), image.BuildPolicy, []string{ + string(BuildPolicyAlways), + string(BuildPolicyIfNeeded), + })) + } + if image.PullRetryLimit != nil && *image.PullRetryLimit < 0 { errorList = append(errorList, field.Invalid(imagePath.Child("pullRetryLimit"), *image.PullRetryLimit, "pullRetryLimit must not be negative")) } if image.Build != nil { - if image.PullPolicy == PullPolicyNever { - errorList = append(errorList, field.Invalid(imagePath.Child("pullPolicy"), image.PullPolicy, "pullPolicy never is not supported for image builds")) - } errorList = append(errorList, validatePhysicalContainerImageBuild(image.Build, imagePath.Child("build"))...) + } else if image.BuildPolicy != "" { + errorList = append(errorList, field.Forbidden(imagePath.Child("buildPolicy"), "buildPolicy can only be set when build is set")) } return errorList @@ -262,14 +292,57 @@ func (pci *PhysicalContainerImage) ValidateUpdate(ctx context.Context, old runti func validatePhysicalContainerImageBuild(build *ContainerBuildContext, buildPath *field.Path) field.ErrorList { errorList := field.ErrorList{} - if build.Context == "" { - errorList = append(errorList, field.Required(buildPath.Child("context"), "context is required")) + if build.Context == "" && build.ContextArchive == nil { + errorList = append(errorList, field.Required(buildPath, "exactly one of context or contextArchive is required")) + } + if build.Context != "" && build.ContextArchive != nil { + errorList = append(errorList, field.Forbidden(buildPath.Child("contextArchive"), "contextArchive cannot be set when context is set")) + } + if build.ContextArchive != nil { + archive := build.ContextArchive + archivePath := buildPath.Child("contextArchive") + if archive.Source == "" && archive.RawContents == "" { + errorList = append(errorList, field.Required(archivePath, "either source or rawContents must be set")) + } + if archive.Source != "" && archive.RawContents != "" { + errorList = append(errorList, field.Forbidden(archivePath.Child("rawContents"), "source and rawContents cannot be set at the same time")) + } + if archive.Source != "" && archive.SHA256 == "" { + errorList = append(errorList, field.Required(archivePath.Child("sha256"), "sha256 must be set when source is specified")) + } + if archive.SHA256 != "" && archive.Source == "" { + errorList = append(errorList, field.Forbidden(archivePath.Child("sha256"), "sha256 can only be set when source is specified")) + } + if archive.SHA256 != "" { + hexPart := archive.SHA256 + if strings.HasPrefix(strings.ToLower(hexPart), "sha256:") { + hexPart = hexPart[7:] + } + if !validSHA256HexRegexp.MatchString(hexPart) { + errorList = append(errorList, field.Invalid(archivePath.Child("sha256"), archive.SHA256, "sha256 must be a 64-character hex string, optionally prefixed with 'sha256:'")) + } + } + if archive.RawContents != "" { + if _, decodeErr := base64.StdEncoding.DecodeString(archive.RawContents); decodeErr != nil { + errorList = append(errorList, field.Invalid(archivePath.Child("rawContents"), "", fmt.Sprintf("rawContents must be valid base64: %s", decodeErr.Error()))) + } + } + // The build context is streamed to the container runtime, so the Dockerfile has to be + // addressable relative to the root of the archive. + if build.Dockerfile != "" && !isArchiveRelativePath(build.Dockerfile) { + errorList = append(errorList, field.Invalid(buildPath.Child("dockerfile"), build.Dockerfile, "dockerfile must be a relative path inside the build context archive")) + } } for i, tag := range build.Tags { if tag == "" || strings.ContainsAny(tag, "\r\n\t ") { errorList = append(errorList, field.Invalid(buildPath.Child("tags").Index(i), tag, "tag must be non-empty and must not contain whitespace or control characters")) } } + for i, baseImage := range build.BaseImages { + if baseImage == "" || strings.ContainsAny(baseImage, "\r\n\t ") { + errorList = append(errorList, field.Invalid(buildPath.Child("baseImages").Index(i), baseImage, "base image must be non-empty and must not contain whitespace or control characters")) + } + } for i, secret := range build.Secrets { secretPath := buildPath.Child("secrets").Index(i) if secret.ID == "" { diff --git a/api/v2/physical_container_image_types_test.go b/api/v2/physical_container_image_types_test.go index 9e3919b8..fabdad73 100644 --- a/api/v2/physical_container_image_types_test.go +++ b/api/v2/physical_container_image_types_test.go @@ -15,6 +15,8 @@ import ( "github.com/microsoft/dcp/pkg/commonapi" ) +const validArchiveSHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + func TestPhysicalContainerImageValidate(t *testing.T) { testCases := []struct { name string @@ -41,6 +43,19 @@ func TestPhysicalContainerImageValidate(t *testing.T) { Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Image: "test-source-image"}}, }, }, + { + name: "valid best effort source image", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{ + Image: "test-source-image", + PullPolicy: PullPolicyBestEffort, + }}, + }, + }, { name: "valid build image without explicit target tag", image: PhysicalContainerImage{ @@ -67,6 +82,99 @@ func TestPhysicalContainerImageValidate(t *testing.T) { }, }, }, + { + name: "valid build image with context archive", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + Digest: "archive-v1", + ContextArchive: &ContainerBuildContextArchive{ + Source: "context.tar", + SHA256: validArchiveSHA256, + }, + }}}, + }, + }, + { + name: "valid best effort build image", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{ + PullPolicy: PullPolicyBestEffort, + Build: &ContainerBuildContext{ + Context: "test-context", + BaseImages: []string{"test-base-image"}, + }, + }}, + }, + }, + { + name: "valid best effort build image without base images", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{ + PullPolicy: PullPolicyBestEffort, + Build: &ContainerBuildContext{ + Context: "test-context", + }, + }}, + }, + }, + { + name: "valid never pull build image", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{ + PullPolicy: PullPolicyNever, + Build: &ContainerBuildContext{ + Context: "test-context", + BaseImages: []string{"test-base-image"}, + }, + }}, + }, + }, + { + name: "valid always build policy", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{ + BuildPolicy: BuildPolicyAlways, + Build: &ContainerBuildContext{ + Context: "test-context", + }, + }}, + }, + }, + { + name: "valid build image with raw context archive", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + Digest: "archive-v1", + ContextArchive: &ContainerBuildContextArchive{ + RawContents: "dGVzdA==", + }, + }}}, + }, + }, { name: "valid build image with file secret", image: PhysicalContainerImage{ @@ -163,19 +271,48 @@ func TestPhysicalContainerImageValidate(t *testing.T) { expectedError: "spec.image.pullPolicy", }, { - name: "never pull policy with build", + name: "invalid build policy", image: PhysicalContainerImage{ ObjectMeta: metav1.ObjectMeta{ Name: "test-image", Namespace: "test-namespace", }, - Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ - Context: "test-context", + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{ + BuildPolicy: "invalid", + Build: &ContainerBuildContext{Context: "test-context"}, + }}, + }, + expectedError: "spec.image.buildPolicy", + }, + { + name: "build policy without build", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", }, - PullPolicy: PullPolicyNever}, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{ + Image: "test-source-image", + BuildPolicy: BuildPolicyAlways, + }}, + }, + expectedError: "spec.image.buildPolicy", + }, + { + name: "build rejects invalid base image", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{ + Build: &ContainerBuildContext{ + Context: "test-context", + BaseImages: []string{"invalid base"}, + }, + }}, }, - expectedError: "spec.image.pullPolicy", + expectedError: "spec.image.build.baseImages[0]", }, { name: "missing build context", @@ -186,7 +323,126 @@ func TestPhysicalContainerImageValidate(t *testing.T) { }, Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{}}}, }, - expectedError: "spec.image.build.context", + expectedError: "spec.image.build", + }, + { + name: "build context conflicts with archive", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + Context: "test-context", + ContextArchive: &ContainerBuildContextArchive{ + Source: "context.tar", + SHA256: validArchiveSHA256, + }, + }}}, + }, + expectedError: "spec.image.build.contextArchive", + }, + { + name: "build context archive missing content", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + ContextArchive: &ContainerBuildContextArchive{}, + }}}, + }, + expectedError: "spec.image.build.contextArchive", + }, + { + name: "build context archive missing hash", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + ContextArchive: &ContainerBuildContextArchive{Source: "context.tar"}, + }}}, + }, + expectedError: "spec.image.build.contextArchive.sha256", + }, + { + name: "build context archive without digest", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + ContextArchive: &ContainerBuildContextArchive{RawContents: "dGVzdA=="}, + }}}, + }, + }, + { + name: "build context archive source conflicts with raw contents", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + ContextArchive: &ContainerBuildContextArchive{ + Source: "context.tar", + SHA256: validArchiveSHA256, + RawContents: "dGVzdA==", + }, + }}}, + }, + expectedError: "spec.image.build.contextArchive.rawContents", + }, + { + name: "build context archive rejects invalid raw contents", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + ContextArchive: &ContainerBuildContextArchive{ + RawContents: "not-base64!!!", + }, + }}}, + }, + expectedError: "spec.image.build.contextArchive.rawContents", + }, + { + name: "build context archive rejects invalid hash", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + ContextArchive: &ContainerBuildContextArchive{ + Source: "context.tar", + SHA256: "deadbeef", + }, + }}}, + }, + expectedError: "spec.image.build.contextArchive.sha256", + }, + { + name: "build context archive hash requires source", + image: PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-image", + Namespace: "test-namespace", + }, + Spec: PhysicalContainerImageSpec{Image: &PhysicalContainerImageConfig{Build: &ContainerBuildContext{ + ContextArchive: &ContainerBuildContextArchive{ + SHA256: validArchiveSHA256, + RawContents: "dGVzdA==", + }, + }}}, + }, + expectedError: "spec.image.build.contextArchive.sha256", }, { name: "missing build file secret source", @@ -276,6 +532,34 @@ func TestPhysicalContainerImageValidate(t *testing.T) { } } +func TestArchiveRelativePath(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + path string + valid bool + }{ + "file at archive root": {path: "Dockerfile", valid: true}, + "file in archive directory": {path: "docker/Dockerfile", valid: true}, + "archive root": {path: "."}, + "archive root with separator": {path: "./"}, + "path collapsing to root": {path: "docker/.."}, + "parent traversal": {path: "../Dockerfile"}, + "backslash in path element": {path: `..\Dockerfile`, valid: true}, + "absolute path": {path: "/Dockerfile"}, + "colon in path element": {path: "C:/Dockerfile", valid: true}, + "backslash in file name": {path: `docker\Dockerfile`, valid: true}, + "leading backslash": {path: `\Dockerfile`, valid: true}, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Equal(t, testCase.valid, isArchiveRelativePath(testCase.path)) + }) + } +} + func TestPhysicalContainerImageValidateUpdateRejectsSpecChanges(t *testing.T) { oldImage := &PhysicalContainerImage{ ObjectMeta: metav1.ObjectMeta{ diff --git a/api/v2/physical_container_network_connection_types.go b/api/v2/physical_container_network_connection_types.go new file mode 100644 index 00000000..38a4514a --- /dev/null +++ b/api/v2/physical_container_network_connection_types.go @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package v2 + +import ( + "context" + "slices" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + + apiserver_resource "github.com/tilt-dev/tilt-apiserver/pkg/server/builder/resource" + apiserver_resourcerest "github.com/tilt-dev/tilt-apiserver/pkg/server/builder/resource/resourcerest" + apiserver_resourcestrategy "github.com/tilt-dev/tilt-apiserver/pkg/server/builder/resource/resourcestrategy" + + "github.com/microsoft/dcp/pkg/commonapi" +) + +// PhysicalContainerNetworkConnectionSpec identifies the physical container and network that should be connected. +// Both references resolve within the connection resource's namespace. +// +k8s:openapi-gen=true +type PhysicalContainerNetworkConnectionSpec struct { + // ContainerRef is the name of the PhysicalContainer to connect. + ContainerRef string `json:"containerRef"` + + // NetworkRef is the name of the PhysicalContainerNetwork to connect to. + NetworkRef string `json:"networkRef"` + + // Aliases contains network-scoped aliases for the container. + // +listType=set + Aliases []string `json:"aliases,omitempty"` +} + +// Equal reports whether two PhysicalContainerNetworkConnectionSpec values are equal. +func (spec PhysicalContainerNetworkConnectionSpec) Equal(other PhysicalContainerNetworkConnectionSpec) bool { + return spec.ContainerRef == other.ContainerRef && + spec.NetworkRef == other.NetworkRef && + slices.Equal(spec.Aliases, other.Aliases) +} + +// PhysicalContainerNetworkConnection represents desired runtime network membership for one physical container. +// +kubebuilder:object:root=true +// +k8s:openapi-gen=true +// +kubebuilder:resource:scope=Namespaced,path=physicalcontainernetworkconnections,shortName=pcnc +type PhysicalContainerNetworkConnection struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PhysicalContainerNetworkConnectionSpec `json:"spec,omitempty"` +} + +func (connection *PhysicalContainerNetworkConnection) GetGroupVersionResource() schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: GroupVersion.Group, + Version: GroupVersion.Version, + Resource: "physicalcontainernetworkconnections", + } +} + +func (connection *PhysicalContainerNetworkConnection) GetObjectMeta() *metav1.ObjectMeta { + return &connection.ObjectMeta +} + +func (connection *PhysicalContainerNetworkConnection) New() runtime.Object { + return &PhysicalContainerNetworkConnection{} +} + +func (connection *PhysicalContainerNetworkConnection) NewList() runtime.Object { + return &PhysicalContainerNetworkConnectionList{} +} + +func (connection *PhysicalContainerNetworkConnection) IsStorageVersion() bool { + return true +} + +func (connection *PhysicalContainerNetworkConnection) NamespaceScoped() bool { + return true +} + +func (connection *PhysicalContainerNetworkConnection) ShortNames() []string { + return []string{"pcnc"} +} + +func (connection *PhysicalContainerNetworkConnection) NamespacedName() types.NamespacedName { + return NamespacedName(connection) +} + +func (connection *PhysicalContainerNetworkConnection) Validate(ctx context.Context) field.ErrorList { + errorList := ValidateNamespacedResourceMetadata(connection) + specPath := field.NewPath("spec") + + if commonapi.ResourceCreationProhibited.Load() && connection.DeletionTimestamp.IsZero() { + errorList = append(errorList, field.Forbidden(nil, commonapi.ErrResourceCreationProhibited.Error())) + } + + errorList = append(errorList, commonapi.ValidateAnnotationsSize(connection.Annotations, field.NewPath("metadata", "annotations"))...) + errorList = append(errorList, validatePhysicalResourceReference(connection.Spec.ContainerRef, specPath.Child("containerRef"))...) + errorList = append(errorList, validatePhysicalResourceReference(connection.Spec.NetworkRef, specPath.Child("networkRef"))...) + return errorList +} + +func (connection *PhysicalContainerNetworkConnection) ValidateUpdate(ctx context.Context, old runtime.Object) field.ErrorList { + oldConnection := old.(*PhysicalContainerNetworkConnection) + if oldConnection.Spec.Equal(connection.Spec) { + return nil + } + + return field.ErrorList{ + field.Forbidden(field.NewPath("spec"), "spec is immutable"), + } +} + +func validatePhysicalResourceReference(reference string, referencePath *field.Path) field.ErrorList { + if reference == "" { + return field.ErrorList{field.Required(referencePath, "reference must be set")} + } + + errorList := field.ErrorList{} + for _, validationMessage := range validation.IsDNS1123Subdomain(reference) { + errorList = append(errorList, field.Invalid(referencePath, reference, validationMessage)) + } + return errorList +} + +// PhysicalContainerNetworkConnectionList contains a list of PhysicalContainerNetworkConnection instances. +// +k8s:openapi-gen=true +// +kubebuilder:object:root=true +type PhysicalContainerNetworkConnectionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []PhysicalContainerNetworkConnection `json:"items"` +} + +func (connections *PhysicalContainerNetworkConnectionList) GetListMeta() *metav1.ListMeta { + return &connections.ListMeta +} + +func (connections *PhysicalContainerNetworkConnectionList) ItemCount() uint32 { + return uint32(len(connections.Items)) +} + +func (connections *PhysicalContainerNetworkConnectionList) GetItems() []*PhysicalContainerNetworkConnection { + result := make([]*PhysicalContainerNetworkConnection, len(connections.Items)) + for i := range connections.Items { + result[i] = &connections.Items[i] + } + return result +} + +func init() { + SchemeBuilder.Register(&PhysicalContainerNetworkConnection{}, &PhysicalContainerNetworkConnectionList{}) +} + +var _ apiserver_resource.Object = (*PhysicalContainerNetworkConnection)(nil) +var _ apiserver_resource.ObjectList = (*PhysicalContainerNetworkConnectionList)(nil) +var _ commonapi.ListWithObjectItems[PhysicalContainerNetworkConnection, *PhysicalContainerNetworkConnection] = (*PhysicalContainerNetworkConnectionList)(nil) +var _ apiserver_resourcerest.ShortNamesProvider = (*PhysicalContainerNetworkConnection)(nil) +var _ apiserver_resourcestrategy.Validater = (*PhysicalContainerNetworkConnection)(nil) +var _ apiserver_resourcestrategy.ValidateUpdater = (*PhysicalContainerNetworkConnection)(nil) diff --git a/api/v2/physical_container_network_connection_types_test.go b/api/v2/physical_container_network_connection_types_test.go new file mode 100644 index 00000000..e965b477 --- /dev/null +++ b/api/v2/physical_container_network_connection_types_test.go @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package v2 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPhysicalContainerNetworkConnectionValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + connection PhysicalContainerNetworkConnection + valid bool + }{ + { + name: "valid", + connection: PhysicalContainerNetworkConnection{ + ObjectMeta: metav1.ObjectMeta{Name: "connection", Namespace: "namespace"}, + Spec: PhysicalContainerNetworkConnectionSpec{ + ContainerRef: "container", + NetworkRef: "network", + Aliases: []string{"alias"}, + }, + }, + valid: true, + }, + { + name: "missing container reference", + connection: PhysicalContainerNetworkConnection{ + ObjectMeta: metav1.ObjectMeta{Name: "connection", Namespace: "namespace"}, + Spec: PhysicalContainerNetworkConnectionSpec{ + NetworkRef: "network", + }, + }, + }, + { + name: "invalid container reference", + connection: PhysicalContainerNetworkConnection{ + ObjectMeta: metav1.ObjectMeta{Name: "connection", Namespace: "namespace"}, + Spec: PhysicalContainerNetworkConnectionSpec{ + ContainerRef: "INVALID", + NetworkRef: "network", + }, + }, + }, + { + name: "missing network reference", + connection: PhysicalContainerNetworkConnection{ + ObjectMeta: metav1.ObjectMeta{Name: "connection", Namespace: "namespace"}, + Spec: PhysicalContainerNetworkConnectionSpec{ + ContainerRef: "container", + }, + }, + }, + { + name: "invalid network reference", + connection: PhysicalContainerNetworkConnection{ + ObjectMeta: metav1.ObjectMeta{Name: "connection", Namespace: "namespace"}, + Spec: PhysicalContainerNetworkConnectionSpec{ + ContainerRef: "container", + NetworkRef: "INVALID", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + errorList := test.connection.Validate(context.Background()) + if test.valid { + require.Empty(t, errorList) + } else { + require.NotEmpty(t, errorList) + } + }) + } +} + +func TestPhysicalContainerNetworkConnectionValidateUpdate(t *testing.T) { + t.Parallel() + + withoutAliases := &PhysicalContainerNetworkConnection{ + Spec: PhysicalContainerNetworkConnectionSpec{ + ContainerRef: "container", + NetworkRef: "network", + }, + } + emptyAliasesUpdate := withoutAliases.DeepCopy() + emptyAliasesUpdate.Spec.Aliases = []string{} + require.Empty(t, emptyAliasesUpdate.ValidateUpdate(context.Background(), withoutAliases)) + + original := &PhysicalContainerNetworkConnection{ + Spec: PhysicalContainerNetworkConnectionSpec{ + ContainerRef: "container", + NetworkRef: "network", + Aliases: []string{"alias"}, + }, + } + + specUpdate := original.DeepCopy() + specUpdate.Spec.NetworkRef = "other-network" + require.NotEmpty(t, specUpdate.ValidateUpdate(context.Background(), original)) +} diff --git a/api/v2/physical_container_network_types.go b/api/v2/physical_container_network_types.go index a9441fcd..2ad552ea 100644 --- a/api/v2/physical_container_network_types.go +++ b/api/v2/physical_container_network_types.go @@ -146,6 +146,10 @@ type PhysicalContainerNetworkStatus struct { // +listType=set Gateways []string `json:"gateways,omitempty"` + // ContainerIDs are the runtime container IDs currently attached to the network. + // +listType=set + ContainerIDs []string `json:"containerIDs,omitempty"` + // CreatedAt is the runtime network creation timestamp. CreatedAt metav1.MicroTime `json:"createdAt,omitempty"` diff --git a/api/v2/physical_container_types.go b/api/v2/physical_container_types.go index 2ee5fe7b..5c96aad0 100644 --- a/api/v2/physical_container_types.go +++ b/api/v2/physical_container_types.go @@ -65,6 +65,15 @@ const ( // PhysicalContainerReasonImageLookupFailed indicates that the referenced PhysicalContainerImage could not be read. PhysicalContainerReasonImageLookupFailed ConditionReason = "ImageLookupFailed" + // PhysicalContainerReasonNetworkNotFound indicates that a referenced PhysicalContainerNetwork does not exist. + PhysicalContainerReasonNetworkNotFound ConditionReason = "NetworkNotFound" + + // PhysicalContainerReasonNetworkNotReady indicates that a referenced PhysicalContainerNetwork is not ready. + PhysicalContainerReasonNetworkNotReady ConditionReason = "NetworkNotReady" + + // PhysicalContainerReasonNetworkLookupFailed indicates that a referenced PhysicalContainerNetwork could not be read. + PhysicalContainerReasonNetworkLookupFailed ConditionReason = "NetworkLookupFailed" + // PhysicalContainerReasonCreating indicates that runtime container creation is in progress. PhysicalContainerReasonCreating ConditionReason = "Creating" @@ -189,7 +198,7 @@ type PhysicalContainerConfig struct { // +listType=atomic VolumeMounts []VolumeMount `json:"volumeMounts,omitempty"` - // Networks describes runtime networks to attach the container to when it is created. + // Networks describes PhysicalContainerNetworks to attach the container to. // If omitted, the container runtime chooses the default network. // +listType=atomic Networks []ContainerNetworkConnectionConfig `json:"networks,omitempty"` @@ -361,9 +370,7 @@ func (pc *PhysicalContainer) Validate(ctx context.Context) field.ErrorList { networksPath := containerPath.Child("networks") for i, network := range container.Networks { - if network.Name == "" { - errorList = append(errorList, field.Required(networksPath.Index(i).Child("name"), "name must be set to a non-empty value")) - } + errorList = append(errorList, validatePhysicalResourceReference(network.Name, networksPath.Index(i).Child("name"))...) } errorList = append(errorList, ValidateContainerPorts(container.Ports, containerPath.Child("ports"))...) errorList = append(errorList, validateLabels(container.Labels, containerPath.Child("labels"))...) diff --git a/api/v2/zz_generated.deepcopy.go b/api/v2/zz_generated.deepcopy.go index 2691f6e1..72d8e1f4 100644 --- a/api/v2/zz_generated.deepcopy.go +++ b/api/v2/zz_generated.deepcopy.go @@ -19,6 +19,11 @@ import ( // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ContainerBuildContext) DeepCopyInto(out *ContainerBuildContext) { *out = *in + if in.ContextArchive != nil { + in, out := &in.ContextArchive, &out.ContextArchive + *out = new(ContainerBuildContextArchive) + **out = **in + } if in.Tags != nil { in, out := &in.Tags, &out.Tags *out = make([]string, len(*in)) @@ -39,6 +44,11 @@ func (in *ContainerBuildContext) DeepCopyInto(out *ContainerBuildContext) { *out = make([]commonapi.Label, len(*in)) copy(*out, *in) } + if in.BaseImages != nil { + in, out := &in.BaseImages, &out.BaseImages + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerBuildContext. @@ -51,6 +61,21 @@ func (in *ContainerBuildContext) DeepCopy() *ContainerBuildContext { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContainerBuildContextArchive) DeepCopyInto(out *ContainerBuildContextArchive) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerBuildContextArchive. +func (in *ContainerBuildContextArchive) DeepCopy() *ContainerBuildContextArchive { + if in == nil { + return nil + } + out := new(ContainerBuildContextArchive) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ContainerBuildSecret) DeepCopyInto(out *ContainerBuildSecret) { *out = *in @@ -531,6 +556,84 @@ func (in *PhysicalContainerNetworkConfig) DeepCopy() *PhysicalContainerNetworkCo return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PhysicalContainerNetworkConnection) DeepCopyInto(out *PhysicalContainerNetworkConnection) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PhysicalContainerNetworkConnection. +func (in *PhysicalContainerNetworkConnection) DeepCopy() *PhysicalContainerNetworkConnection { + if in == nil { + return nil + } + out := new(PhysicalContainerNetworkConnection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PhysicalContainerNetworkConnection) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PhysicalContainerNetworkConnectionList) DeepCopyInto(out *PhysicalContainerNetworkConnectionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PhysicalContainerNetworkConnection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PhysicalContainerNetworkConnectionList. +func (in *PhysicalContainerNetworkConnectionList) DeepCopy() *PhysicalContainerNetworkConnectionList { + if in == nil { + return nil + } + out := new(PhysicalContainerNetworkConnectionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PhysicalContainerNetworkConnectionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PhysicalContainerNetworkConnectionSpec) DeepCopyInto(out *PhysicalContainerNetworkConnectionSpec) { + *out = *in + if in.Aliases != nil { + in, out := &in.Aliases, &out.Aliases + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PhysicalContainerNetworkConnectionSpec. +func (in *PhysicalContainerNetworkConnectionSpec) DeepCopy() *PhysicalContainerNetworkConnectionSpec { + if in == nil { + return nil + } + out := new(PhysicalContainerNetworkConnectionSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PhysicalContainerNetworkList) DeepCopyInto(out *PhysicalContainerNetworkList) { *out = *in @@ -596,6 +699,11 @@ func (in *PhysicalContainerNetworkStatus) DeepCopyInto(out *PhysicalContainerNet *out = make([]string, len(*in)) copy(*out, *in) } + if in.ContainerIDs != nil { + in, out := &in.ContainerIDs, &out.ContainerIDs + *out = make([]string, len(*in)) + copy(*out, *in) + } in.CreatedAt.DeepCopyInto(&out.CreatedAt) if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions diff --git a/api/v2/zz_generated.model_name.go b/api/v2/zz_generated.model_name.go index 6b67fd53..75cdabdd 100644 --- a/api/v2/zz_generated.model_name.go +++ b/api/v2/zz_generated.model_name.go @@ -15,6 +15,11 @@ func (in ContainerBuildContext) OpenAPIModelName() string { return "github.com/microsoft/dcp/api/v2.ContainerBuildContext" } +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in ContainerBuildContextArchive) OpenAPIModelName() string { + return "github.com/microsoft/dcp/api/v2.ContainerBuildContextArchive" +} + // OpenAPIModelName returns the OpenAPI model name for this type. func (in ContainerBuildSecret) OpenAPIModelName() string { return "github.com/microsoft/dcp/api/v2.ContainerBuildSecret" @@ -105,6 +110,21 @@ func (in PhysicalContainerNetworkConfig) OpenAPIModelName() string { return "github.com/microsoft/dcp/api/v2.PhysicalContainerNetworkConfig" } +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in PhysicalContainerNetworkConnection) OpenAPIModelName() string { + return "github.com/microsoft/dcp/api/v2.PhysicalContainerNetworkConnection" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in PhysicalContainerNetworkConnectionList) OpenAPIModelName() string { + return "github.com/microsoft/dcp/api/v2.PhysicalContainerNetworkConnectionList" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in PhysicalContainerNetworkConnectionSpec) OpenAPIModelName() string { + return "github.com/microsoft/dcp/api/v2.PhysicalContainerNetworkConnectionSpec" +} + // OpenAPIModelName returns the OpenAPI model name for this type. func (in PhysicalContainerNetworkList) OpenAPIModelName() string { return "github.com/microsoft/dcp/api/v2.PhysicalContainerNetworkList" diff --git a/controllers/container_controller.go b/controllers/container_controller.go index 63a8224e..4334267f 100644 --- a/controllers/container_controller.go +++ b/controllers/container_controller.go @@ -59,7 +59,7 @@ const ( stopContainerTimeoutSeconds = 10 containerInspectionTimeout = 6 * time.Second - ownerKey = ".metadata.controllerOwner" // client index key for child ContainerNetworkConnections + ownerKey = ".metadata.controllerOwner" // client index key for controller-owned network connections dcpBuildLabel = "com.microsoft.developer.usvc-dev.build" groupVersionLabel = "com.microsoft.developer.usvc-dev.group-version" nameLabel = "com.microsoft.developer.usvc-dev.name" diff --git a/controllers/container_network_tunnel_proxy_controller.go b/controllers/container_network_tunnel_proxy_controller.go index 56d8bfeb..baefe4bb 100644 --- a/controllers/container_network_tunnel_proxy_controller.go +++ b/controllers/container_network_tunnel_proxy_controller.go @@ -15,7 +15,6 @@ import ( "os" "os/exec" "strconv" - "sync" "time" "github.com/cenkalti/backoff/v4" @@ -24,6 +23,7 @@ import ( "google.golang.org/grpc/credentials" stdproto "google.golang.org/protobuf/proto" apimachinery_errors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -35,13 +35,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" apiv1 "github.com/microsoft/dcp/api/v1" - "github.com/microsoft/dcp/internal/containers" + apiv2 "github.com/microsoft/dcp/api/v2" "github.com/microsoft/dcp/internal/dcppaths" "github.com/microsoft/dcp/internal/dcpproc" "github.com/microsoft/dcp/internal/dcptun" dcptunproto "github.com/microsoft/dcp/internal/dcptun/proto" "github.com/microsoft/dcp/internal/networking" "github.com/microsoft/dcp/pkg/commonapi" + "github.com/microsoft/dcp/pkg/concurrency" usvc_io "github.com/microsoft/dcp/pkg/io" "github.com/microsoft/dcp/pkg/logger" "github.com/microsoft/dcp/pkg/maps" @@ -67,8 +68,7 @@ const ( containerNetworkNameKey = ".metadata.containerNetworkName" serviceReferencesKey = ".metadata.serviceReferences" - clientProxyContainerCleanupTimeout = 5 * time.Second - serverProxyConfigReadTimeout = 10 * time.Second + serverProxyConfigReadTimeout = 10 * time.Second // Timeout for tunnel operations (like preparation or deletion of a tunnel) tunnelOperationTimeout = 5 * time.Second @@ -77,6 +77,9 @@ const ( // Annotation for an Endpoint object that links it to a specific tunnel that serves it. TunnelIdAnnotation = "container-network-tunnel-proxy.usvc-dev.developer.microsoft.com/tunnel-id" + + tunnelProxyOwnerNameAnnotation = "container-network-tunnel-proxy.usvc-dev.developer.microsoft.com/owner-name" + tunnelProxyOwnerNamespaceAnnotation = "container-network-tunnel-proxy.usvc-dev.developer.microsoft.com/owner-namespace" ) var ( @@ -93,30 +96,20 @@ var ( ) type ContainerNetworkTunnelProxyReconcilerConfig struct { - Orchestrator containers.ContainerOrchestrator // Mandatory - ProcessExecutor process.Executor // Mandatory + ProcessExecutor process.Executor // Mandatory // The factory function to create a TunnelControlClient used to control the proxy pair. // Normal execution uses "real" gRPC client, tests use a stub since most tests do not run real tunnels. // Mandatory. MakeTunnelControlClient func(grpc.ClientConnInterface) dcptunproto.TunnelControlClient - // Overrides the most recent image builds file path. - // Used primarily for testing purposes. - MostRecentImageBuildsFilePath string - // Specifies how many attempts to prepare a tunnel will be made before giving up and marking the tunnel as failed. // Defaults to defaultMaxTunnelPreparationAttempts, but much lower value is used for tests to simulate failures quickly. MaxTunnelPreparationAttempts uint32 - - // If not zero, specifies how long the controller will wait for an attempt to start the client proxy container to succeed. - // Used primarily for testing purposes. - ContainerStartupTimeoutOverride time.Duration } type ContainerNetworkTunnelProxyReconciler struct { *ReconcilerBase[apiv1.ContainerNetworkTunnelProxy, *apiv1.ContainerNetworkTunnelProxy] - *ContainerWatcher[apiv1.ContainerNetworkTunnelProxy] config ContainerNetworkTunnelProxyReconcilerConfig @@ -125,6 +118,8 @@ type ContainerNetworkTunnelProxyReconciler struct { // A work queue for long-running operations. workQueue *resiliency.WorkQueue + + sharedImagePreparationLock *concurrency.ContextAwareLock } func NewContainerNetworkTunnelProxyReconciler( @@ -134,9 +129,6 @@ func NewContainerNetworkTunnelProxyReconciler( config ContainerNetworkTunnelProxyReconcilerConfig, log logr.Logger, ) *ContainerNetworkTunnelProxyReconciler { - if config.Orchestrator == nil { - panic("ContainerNetworkTunnelProxyReconcilerConfig.Orchestrator must not be nil") - } if config.ProcessExecutor == nil { panic("ContainerNetworkTunnelProxyReconcilerConfig.ProcessExecutor must not be nil") } @@ -148,16 +140,14 @@ func NewContainerNetworkTunnelProxyReconciler( } base := NewReconcilerBase[apiv1.ContainerNetworkTunnelProxy](client, noCacheClient, log, lifetimeCtx) - containerWatcher := NewContainerWatcher[apiv1.ContainerNetworkTunnelProxy](config.Orchestrator, &sync.Mutex{}, lifetimeCtx) r := ContainerNetworkTunnelProxyReconciler{ - ReconcilerBase: base, - ContainerWatcher: containerWatcher, - config: config, - proxyData: NewObjectStateMap[types.NamespacedName, containerNetworkTunnelProxyData, *containerNetworkTunnelProxyData, *apiv1.ContainerNetworkTunnelProxy](), - workQueue: resiliency.NewWorkQueue(lifetimeCtx, resiliency.DefaultConcurrency), + ReconcilerBase: base, + config: config, + proxyData: NewObjectStateMap[types.NamespacedName, containerNetworkTunnelProxyData, *containerNetworkTunnelProxyData, *apiv1.ContainerNetworkTunnelProxy](), + workQueue: resiliency.NewWorkQueue(lifetimeCtx, resiliency.DefaultConcurrency), + sharedImagePreparationLock: concurrency.NewContextAwareLock(), } - containerWatcher.ProcessContainerEvent = r.processContainerEvent return &r } @@ -201,11 +191,67 @@ func (r *ContainerNetworkTunnelProxyReconciler) SetupWithManager(mgr ctrl.Manage Owns(&apiv1.Endpoint{}). Watches(&apiv1.Service{}, handler.EnqueueRequestsFromMapFunc(r.reconcileProxiesUsingService), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). Watches(&apiv1.ContainerNetwork{}, handler.EnqueueRequestsFromMapFunc(r.reconcileProxiesUsingNetwork), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Watches(&apiv2.PhysicalContainerImage{}, handler.EnqueueRequestsFromMapFunc(r.reconcileProxyForPhysicalResource), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Watches(&apiv2.PhysicalContainer{}, handler.EnqueueRequestsFromMapFunc(r.reconcileProxyForPhysicalResource), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Watches(&apiv2.PhysicalContainerNetwork{}, handler.EnqueueRequestsFromMapFunc(r.reconcileProxyForPhysicalResource), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). WatchesRawSource(r.GetReconciliationEventSource()). Named(name). Complete(r) } +func (r *ContainerNetworkTunnelProxyReconciler) reconcileProxyForPhysicalResource(ctx context.Context, obj ctrl_client.Object) []reconcile.Request { + if obj.GetNamespace() != V1PhysicalResourcesNamespaceName { + return nil + } + + if _, isImage := obj.(*apiv2.PhysicalContainerImage); isImage { + if obj.GetName() != V1TunnelProxyPhysicalContainerImageName { + return nil + } + + tunnelProxies := apiv1.ContainerNetworkTunnelProxyList{} + if listErr := r.List(ctx, &tunnelProxies); listErr != nil { + r.Log.Error(listErr, "Failed to list ContainerNetworkTunnelProxies for shared PhysicalContainerImage") + return nil + } + + return slices.Map[reconcile.Request](tunnelProxies.Items, func(tunnelProxy apiv1.ContainerNetworkTunnelProxy) reconcile.Request { + return reconcile.Request{NamespacedName: tunnelProxy.NamespacedName()} + }) + } + + annotations := obj.GetAnnotations() + if _, isNetwork := obj.(*apiv2.PhysicalContainerNetwork); isNetwork { + sourceNetworkName := annotations[V1ContainerNetworkNameAnnotation] + if sourceNetworkName == "" { + return nil + } + + tunnelProxies := apiv1.ContainerNetworkTunnelProxyList{} + listErr := r.List(ctx, &tunnelProxies, ctrl_client.MatchingFields{containerNetworkNameKey: sourceNetworkName}) + if listErr != nil { + r.Log.Error(listErr, "Failed to list ContainerNetworkTunnelProxies for shared PhysicalContainerNetwork") + return nil + } + + return slices.Map[reconcile.Request](tunnelProxies.Items, func(tunnelProxy apiv1.ContainerNetworkTunnelProxy) reconcile.Request { + return reconcile.Request{NamespacedName: tunnelProxy.NamespacedName()} + }) + } + + ownerName := annotations[tunnelProxyOwnerNameAnnotation] + if ownerName == "" { + return nil + } + + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Namespace: annotations[tunnelProxyOwnerNamespaceAnnotation], + Name: ownerName, + }, + }} +} + // Create reconciliation requests for all ContainerNetworkTunnelProxies using the given ContainerNetwork func (r *ContainerNetworkTunnelProxyReconciler) reconcileProxiesUsingNetwork(ctx context.Context, obj ctrl_client.Object) []reconcile.Request { network := obj.(*apiv1.ContainerNetwork) @@ -312,24 +358,26 @@ func (r *ContainerNetworkTunnelProxyReconciler) Reconcile(ctx context.Context, r func (r *ContainerNetworkTunnelProxyReconciler) handleDeletionRequest(ctx context.Context, tunnelProxy *apiv1.ContainerNetworkTunnelProxy, log logr.Logger) objectChange { namespacedName := tunnelProxy.NamespacedName() _, pd := r.proxyData.BorrowByNamespacedName(namespacedName) + if pd == nil { + pd = newContainerNetworkTunnelProxyData(tunnelProxy.Status.State) + pd.ContainerNetworkTunnelProxyStatus = *tunnelProxy.Status.DeepCopy() + r.proxyData.Store(namespacedName, namespacedName, pd) + } var change objectChange = noChange switch { - case pd == nil || pd.State == apiv1.ContainerNetworkTunnelProxyStateFailed || pd.State == apiv1.ContainerNetworkTunnelProxyStateEmpty || pd.State == apiv1.ContainerNetworkTunnelProxyStatePending: - log.V(1).Info("ContainerNetworkTunnelProxy is being deleted (no resources to clean up, deleting finalizer only)...") - change = deleteFinalizer(tunnelProxy, tunnelProxyFinalizer, log) - - case pd.State == apiv1.ContainerNetworkTunnelProxyStateBuildingImage || pd.State == apiv1.ContainerNetworkTunnelProxyStateStarting: - log.V(1).Info("ContainerNetworkTunnelProxy is being deleted; waiting for it to exit transient state...") - change = r.manageTunnelProxy(ctx, tunnelProxy, log) + case pd.startupScheduled: + log.V(1).Info("ContainerNetworkTunnelProxy is being deleted; waiting for scheduled startup work to finish...") + change = additionalReconciliationNeeded - case pd.ServerProxyProcessID == nil && pd.ClientProxyContainerID == "": + case pd.cleanupCompleted && pd.ServerProxyProcessID == nil && pd.ClientProxyContainerID == "": log.V(1).Info("ContainerNetworkTunnelProxy is being deleted (resource cleanup finished, deleting finalizer)...") change = deleteFinalizer(tunnelProxy, tunnelProxyFinalizer, log) default: if !pd.cleanupScheduled { pd.cleanupScheduled = true + pd.cleanupCompleted = false r.proxyData.Update(namespacedName, namespacedName, pd) log.V(1).Info("ContainerNetworkTunnelProxy is being deleted (scheduling resource cleanup)...") @@ -428,20 +476,71 @@ func ensureTunnelProxyBuildingImageState( change := noChange if pd == nil { - log.V(1).Info("Making sure the container proxy image is up to date...") + log.V(1).Info("Ensuring the shared tunnel proxy PhysicalContainerImage exists...") pd = newContainerNetworkTunnelProxyData(apiv1.ContainerNetworkTunnelProxyStateBuildingImage) + r.proxyData.Store(tunnelProxy.NamespacedName(), tunnelProxy.NamespacedName(), pd) + } - startImgCheckErr := r.workQueue.Enqueue(r.ensureContainerProxyImage(tunnelProxy, pd.Clone(), log)) - if startImgCheckErr != nil { - log.Error(startImgCheckErr, "Container image check for container network tunnel could not be queued, possibly because the workload is shutting down") + physicalImage := apiv2.PhysicalContainerImage{} + getImageErr := r.Get(ctx, tunnelProxyPhysicalImageName(), &physicalImage) + imageReady := false + switch { + case apimachinery_errors.IsNotFound(getImageErr): + pd.Message = fmt.Sprintf("Waiting for shared tunnel proxy PhysicalContainerImage %q to be created", tunnelProxyPhysicalImageName().String()) + namespaceErr := EnsureV1PhysicalResourcesNamespace(ctx, r.Client) + if namespaceErr != nil { + log.Error(namespaceErr, "Failed to ensure V1 physical resources namespace") + pd.Message = fmt.Sprintf("Failed to ensure V1 physical resources namespace: %v", namespaceErr) + change |= additionalReconciliationNeeded + break + } + scheduleImageErr := r.scheduleTunnelProxyPhysicalContainerImageCreation(tunnelProxy.NamespacedName(), log) + if scheduleImageErr != nil { + log.Error(scheduleImageErr, "Failed to schedule shared tunnel proxy PhysicalContainerImage creation") + pd.Message = fmt.Sprintf("Failed to schedule shared tunnel proxy PhysicalContainerImage creation: %v", scheduleImageErr) + change |= additionalReconciliationNeeded + break + } + change |= additionalReconciliationNeeded + case getImageErr != nil: + log.Error(getImageErr, "Failed to get shared tunnel proxy PhysicalContainerImage") + pd.Message = fmt.Sprintf("Failed to get shared tunnel proxy PhysicalContainerImage: %v", getImageErr) + change |= additionalReconciliationNeeded + default: + switch physicalImage.Status.Phase { + case apiv2.PhysicalContainerImagePhaseFailed: + pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed + pd.Message = fmt.Sprintf("PhysicalContainerImage for container network tunnel failed: %s", physicalResourceStatusMessage(physicalImage.Status.Conditions)) + case apiv2.PhysicalContainerImagePhaseReady: + pd.ClientProxyContainerImage = physicalImage.Status.Image + imageReady = true + default: + pd.Message = fmt.Sprintf( + "Waiting for shared tunnel proxy PhysicalContainerImage %q: %s", + tunnelProxyPhysicalImageName().String(), + physicalResourceStatusMessage(physicalImage.Status.Conditions), + ) change |= additionalReconciliationNeeded } + } - r.proxyData.Store(tunnelProxy.NamespacedName(), tunnelProxy.NamespacedName(), pd) + if pd.State != apiv1.ContainerNetworkTunnelProxyStateFailed { + certErr := r.createProxyConnectionCertificates(pd, log) + if certErr != nil { + pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed + pd.Message = fmt.Sprintf("Failed to create tunnel proxy connection certificates: %v", certErr) + } else { + _, containerDelay := r.startClientProxy(ctx, tunnelProxy, pd, log) + if containerDelay != NoDelay { + change |= additionalReconciliationNeeded + } + if pd.State != apiv1.ContainerNetworkTunnelProxyStateFailed && imageReady { + pd.State = apiv1.ContainerNetworkTunnelProxyStateStarting + } + } } - // Regardless whether we just scheduled an image check, or it has been going for a while, - // we need to ensure that the object state is correct. + // Ensure the object reflects the latest physical resource state. return change | pd.applyTo(tunnelProxy) } @@ -493,6 +592,22 @@ func ensureTunnelProxyRunningState( return r.setTunnelProxyState(tunnelProxy, apiv1.ContainerNetworkTunnelProxyStateFailed) } + containerNetwork := apiv1.ContainerNetwork{} + containerNetworkName := types.NamespacedName{Name: tunnelProxy.Spec.ContainerNetworkName} + getNetworkErr := r.Get(ctx, containerNetworkName, &containerNetwork) + if getNetworkErr != nil { + log.Error(getNetworkErr, "Failed to get ContainerNetwork for running tunnel proxy", "ContainerNetwork", containerNetworkName) + return additionalReconciliationNeeded | pd.applyTo(tunnelProxy) + } + clientContainerReady, clientContainerDelay := r.updateClientProxyContainerStatus(ctx, tunnelProxy, &containerNetwork, pd, log) + if !clientContainerReady { + change := pd.applyTo(tunnelProxy) + if clientContainerDelay != NoDelay { + change |= additionalReconciliationNeeded + } + return change + } + change := r.manageTunnels(ctx, tunnelProxy, pd, log) ensureEndpointsForWorkload(ctx, r, tunnelProxy, nil, pd, log) @@ -507,9 +622,21 @@ func ensureTunnelProxyFailedState( pd *containerNetworkTunnelProxyData, log logr.Logger, ) objectChange { + // Supported controller paths initialize proxy data before entering Failed. + // This guard only prevents an invariant violation from crashing the controller. + if pd == nil { + log.Error(fmt.Errorf("data about ContainerNetworkTunnelProxy object is missing"), "", + "CurrentState", apiv1.ContainerNetworkTunnelProxyStateFailed, + ) + return noChange + } + change := r.failAllExistingTunnels(tunnelProxy, pd) pd.cleanupScheduled = true r.cleanupProxyPair(ctx, pd, tunnelProxy.UID, log) + if !pd.cleanupScheduled { + change |= additionalReconciliationNeeded + } removeEndpointsForWorkload(ctx, r, tunnelProxy, log) return change | pd.applyTo(tunnelProxy) } @@ -860,48 +987,6 @@ func (r *ContainerNetworkTunnelProxyReconciler) createProxyClient( // INITIALIZATION AND SHUTDOWN HELPER METHODS -// Returns a function that ensures the container proxy image is up to date. -// The method is called as part of the reconciliation loop, but the returned function is executed asynchronously. -// The passed proxy data is a clone independent from what is stored in r.proxyData map. -func (r *ContainerNetworkTunnelProxyReconciler) ensureContainerProxyImage( - tunnelProxy *apiv1.ContainerNetworkTunnelProxy, - pd *containerNetworkTunnelProxyData, - log logr.Logger, -) func(context.Context) { - return func(ctx context.Context) { - opts := dcptun.BuildClientProxyImageOptions{ - // TODO: set StreamCommandOptions here to capture the logs of the image build process - MostRecentImageBuildsFilePath: r.config.MostRecentImageBuildsFilePath, - } - - image, imageCheckErr := dcptun.EnsureClientProxyImage(ctx, opts, r.config.Orchestrator, log) - - if imageCheckErr != nil { - var rtUnhealthyErr *dcptun.ErrContainerRuntimeUnhealthy - if errors.As(imageCheckErr, &rtUnhealthyErr) { - log.V(1).Info("Container runtime is unhealthy, will retry client proxy image check later") - r.ScheduleReconciliationWithDelay(tunnelProxy.NamespacedName(), LongDelay) - return - } - - log.Error(imageCheckErr, "Container image for container network tunnel could not be built, or its presence could not be verified") - pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - pd.Message = fmt.Sprintf("Container image for container network tunnel could not be built, or its presence could not be verified: %v", imageCheckErr) - } else { - log.V(1).Info("Container image check for container network tunnel completed successfully", "Image", image) - pd.State = apiv1.ContainerNetworkTunnelProxyStateStarting - pd.ClientProxyContainerImage = image - } - - nn := tunnelProxy.NamespacedName() - pdMap := r.proxyData - pdMap.QueueDeferredOp(nn, func(types.NamespacedName, types.NamespacedName, *apiv1.ContainerNetworkTunnelProxy) { - pdMap.Update(nn, nn, pd) - }) - r.ScheduleReconciliation(nn) - } -} - // Returns a function that starts the tunnel proxy pair. // The method is called as part of the reconciliation loop, but the returned function is executed asynchronously. // The passed proxy data is a clone independent from what is stored in r.proxyData map. @@ -1000,192 +1085,361 @@ func (r *ContainerNetworkTunnelProxyReconciler) startClientProxy( pd *containerNetworkTunnelProxyData, log logr.Logger, ) (bool, AdditionalReconciliationDelay) { - if pd.ClientProxyContainerID != "" { - log.V(1).Info("Client proxy container is already running, nothing to do...") - return true, NoDelay - } - - clientProxyCtrName, _, nameErr := MakeUniqueName(tunnelProxy.Name) - if nameErr != nil { - // This would be quite unusual and mean the random number generator failed. - log.Error(nameErr, "Failed to create a unique name for the client proxy container") - pd.startupScheduled = false // Reset startupScheduled flag as means of forcing a retry after potentially transient error. - return false, StandardDelay - } - containerNetworkName := commonapi.AsNamespacedName(tunnelProxy.Spec.ContainerNetworkName, tunnelProxy.Namespace) containerNetwork := apiv1.ContainerNetwork{} cnErr := r.Get(ctx, containerNetworkName, &containerNetwork) if cnErr != nil { log.Error(cnErr, "Failed to retrieve ContainerNetwork data necessary for starting the client proxy container") + pd.Message = fmt.Sprintf("Failed to retrieve ContainerNetwork %q necessary for starting the client proxy container: %v", containerNetworkName.String(), cnErr) pd.startupScheduled = false return false, StandardDelay } if containerNetwork.Status.State != apiv1.ContainerNetworkStateRunning || containerNetwork.Status.ID == "" || containerNetwork.Status.NetworkName == "" { log.V(1).Info("Referenced ContainerNetwork is not in Running state, cannot start the client proxy container") + pd.Message = fmt.Sprintf("Waiting for referenced ContainerNetwork %q to be running", containerNetworkName.String()) pd.startupScheduled = false return false, StandardDelay } - log.V(1).Info("Starting client proxy container...") - - createOpts := containers.CreateContainerOptions{ - Image: pd.ClientProxyContainerImage, - Entrypoint: dcptun.ClientProxyBinaryPath, - Command: append([]string{ - "client", - "--client-control-address", networking.IPv4AllInterfaceAddress, - "--client-control-port", strconv.Itoa(dcptun.DefaultContainerProxyControlPort), - "--client-data-address", networking.IPv4AllInterfaceAddress, - "--client-data-port", strconv.Itoa(dcptun.DefaultContainerProxyDataPort), - }, r.createProxySecurityArgs(pd, log)...), - Ports: []containers.CreateContainerPort{ - {ContainerPort: dcptun.DefaultContainerProxyControlPort}, - {ContainerPort: dcptun.DefaultContainerProxyDataPort}, - }, - Name: clientProxyCtrName, - Networks: []containers.CreateContainerNetworkOptions{ - { - Name: containerNetwork.Status.ID, - Aliases: tunnelProxy.Spec.Aliases, + networkReferenceErr := EnsureV1PhysicalContainerNetworkReference(ctx, r.Client, &containerNetwork) + if networkReferenceErr != nil { + log.Error(networkReferenceErr, "Failed to ensure client proxy PhysicalContainerNetwork") + pd.Message = fmt.Sprintf("Failed to ensure client proxy PhysicalContainerNetwork: %v", networkReferenceErr) + return false, StandardDelay + } + + physicalContainerName := tunnelProxyPhysicalContainerName(tunnelProxy) + physicalContainer := apiv2.PhysicalContainer{} + getContainerErr := r.Get(ctx, physicalContainerName, &physicalContainer) + if apimachinery_errors.IsNotFound(getContainerErr) { + physicalContainer = apiv2.PhysicalContainer{ + ObjectMeta: metav1.ObjectMeta{ + Name: physicalContainerName.Name, + Namespace: physicalContainerName.Namespace, + Annotations: tunnelProxyPhysicalResourceAnnotations(tunnelProxy), }, - }, + Spec: apiv2.PhysicalContainerSpec{ + Container: &apiv2.PhysicalContainerConfig{ + ImageRef: V1TunnelProxyPhysicalContainerImageName, + ContainerName: physicalContainerName.Name, + Entrypoint: dcptun.ClientProxyBinaryPath, + Command: append([]string{ + "client", + "--client-control-address", networking.IPv4AllInterfaceAddress, + "--client-control-port", strconv.Itoa(dcptun.DefaultContainerProxyControlPort), + "--client-data-address", networking.IPv4AllInterfaceAddress, + "--client-data-port", strconv.Itoa(dcptun.DefaultContainerProxyDataPort), + }, r.createProxySecurityArgs(pd, log)...), + Ports: []apiv2.ContainerPort{ + {ContainerPort: int32(dcptun.DefaultContainerProxyControlPort)}, + {ContainerPort: int32(dcptun.DefaultContainerProxyDataPort)}, + }, + Networks: []apiv2.ContainerNetworkConnectionConfig{ + { + Name: V1PhysicalContainerNetworkReferenceName(containerNetwork.UID).Name, + Aliases: tunnelProxy.Spec.Aliases, + }, + }, + }, + }, + } + createContainerErr := r.Client.Create(ctx, &physicalContainer) + if createContainerErr != nil && !apimachinery_errors.IsAlreadyExists(createContainerErr) { + if !commonapi.ResourceCreationProhibited.Load() { + log.Error(createContainerErr, "Failed to create client proxy PhysicalContainer") + } + pd.Message = fmt.Sprintf("Failed to create client proxy PhysicalContainer %q: %v", physicalContainerName.String(), createContainerErr) + return false, StandardDelay + } + + log.V(1).Info("Created client proxy PhysicalContainer", "PhysicalContainer", physicalContainerName) + pd.Message = fmt.Sprintf("Waiting for client proxy PhysicalContainer %q to start", physicalContainerName.String()) + return false, StandardDelay + } + if getContainerErr != nil { + log.Error(getContainerErr, "Failed to get client proxy PhysicalContainer") + pd.Message = fmt.Sprintf("Failed to get client proxy PhysicalContainer %q: %v", physicalContainerName.String(), getContainerErr) + return false, StandardDelay } - thisProcess, thisProcessErr := process.This() - if thisProcessErr != nil { - log.Error(thisProcessErr, "could not get the current process information; container will not have creator process information") - } else { - createOpts.Labels = append(createOpts.Labels, commonapi.Label{ - Key: CreatorProcessIdLabel, - Value: fmt.Sprintf("%d", thisProcess.Pid), - }) - createOpts.Labels = append(createOpts.Labels, commonapi.Label{ - Key: CreatorProcessStartTimeLabel, - Value: thisProcess.IdentityTime.Format(osutil.RFC3339MiliTimestampFormat), - }) + return r.updateClientProxyContainerStatus(ctx, tunnelProxy, &containerNetwork, pd, log) +} + +func tunnelProxyPhysicalImageName() types.NamespacedName { + return types.NamespacedName{ + Namespace: V1PhysicalResourcesNamespaceName, + Name: V1TunnelProxyPhysicalContainerImageName, } +} - created, createErr := createContainer(ctx, r.config.Orchestrator, createOpts) - if createErr != nil { - log.Error(createErr, "Failed to create client proxy container") - pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - pd.Message = fmt.Sprintf("Failed to create client proxy container: %v", createErr) - return false, NoDelay +func tunnelProxyPhysicalContainerName(tunnelProxy *apiv1.ContainerNetworkTunnelProxy) types.NamespacedName { + return tunnelProxyPhysicalContainerNameForUID(tunnelProxy.UID) +} + +func tunnelProxyPhysicalContainerNameForUID(tunnelProxyUID types.UID) types.NamespacedName { + return types.NamespacedName{ + Namespace: V1PhysicalResourcesNamespaceName, + Name: fmt.Sprintf("tunnel-proxy-%s", tunnelProxyUID), } +} - pd.ClientProxyContainerID = created.Id - cleanupContainer := true - defer func() { - if !cleanupContainer { - return - } - ctrErr := r.cleanupClientContainer(ctx, created.Id, tunnelProxy.UID, log) - if ctrErr != nil { - log.Error(ctrErr, "Failed to clean up client proxy container after unsuccessful start") - } - pd.ClientProxyContainerID = "" - }() +func tunnelProxyPhysicalResourceAnnotations(tunnelProxy *apiv1.ContainerNetworkTunnelProxy) map[string]string { + return map[string]string{ + tunnelProxyOwnerNameAnnotation: tunnelProxy.Name, + tunnelProxyOwnerNamespaceAnnotation: tunnelProxy.Namespace, + } +} - r.ContainerWatcher.EnsureContainerWatchForResource(tunnelProxy.UID, log) +func (r *ContainerNetworkTunnelProxyReconciler) ensureTunnelProxyPhysicalContainerImage( + ctx context.Context, + log logr.Logger, +) error { + imageName := tunnelProxyPhysicalImageName() + existingImage := apiv2.PhysicalContainerImage{} + getErr := r.NoCacheClient.Get(ctx, imageName, &existingImage) + if getErr == nil { + return nil + } + if !apimachinery_errors.IsNotFound(getErr) { + return fmt.Errorf("get PhysicalContainerImage %q: %w", imageName.String(), getErr) + } - cncName, _, nameErr := MakeUniqueName(fmt.Sprintf("%s", tunnelProxy.Name)) - if nameErr != nil { - // Should never happen - log.Error(nameErr, "Failed to create a unique name for the ContainerNetworkConnection object") - pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - return false, NoDelay + imagePlan, prepareErr := dcptun.PrepareClientProxyImageBuild(ctx) + if prepareErr != nil { + return prepareErr + } + imageConfig := &apiv2.PhysicalContainerImageConfig{ + Image: imagePlan.Image, + PullPolicy: apiv2.PullPolicyBestEffort, + BuildPolicy: apiv2.BuildPolicyIfNeeded, + Build: &apiv2.ContainerBuildContext{ + Digest: imagePlan.BuildContextDigest, + ContextArchive: &apiv2.ContainerBuildContextArchive{ + Source: imagePlan.BuildContextArchive.Source, + SHA256: imagePlan.BuildContextArchive.SHA256, + RawContents: imagePlan.BuildContextArchive.RawContents, + }, + Dockerfile: imagePlan.Dockerfile, + BaseImages: []string{ + dcptun.DefaultBaseImage, + }, + }, } - cnc := &apiv1.ContainerNetworkConnection{ + physicalImage := &apiv2.PhysicalContainerImage{ ObjectMeta: metav1.ObjectMeta{ - Name: cncName, - Namespace: tunnelProxy.Namespace, - Labels: map[string]string{ - ContainerIdLabel: MakeValidLabelValue(created.Id), // for easier lookup during cleanup - }, + Name: imageName.Name, + Namespace: imageName.Namespace, }, - Spec: apiv1.ContainerNetworkConnectionSpec{ - ContainerNetworkName: containerNetworkName.String(), - ContainerID: created.Id, - Aliases: tunnelProxy.Spec.Aliases, + Spec: apiv2.PhysicalContainerImageSpec{ + Image: imageConfig, }, } - cncCtrlRefErr := ctrl.SetControllerReference(tunnelProxy, cnc, r.Scheme()) - if cncCtrlRefErr != nil { - // Should never happen - log.Error(cncCtrlRefErr, "Failed to set controller reference on ContainerNetworkConnection object") - pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - return false, NoDelay - } - cncErr := r.Client.Create(ctx, cnc) - if cncErr != nil { - if !apiv1.ResourceCreationProhibited.Load() { - log.Error(cncErr, "Failed to create ContainerNetworkConnection object for client proxy container") + createErr := r.Client.Create(ctx, physicalImage) + if createErr != nil { + currentImage := apiv2.PhysicalContainerImage{} + lookupErr := r.NoCacheClient.Get(ctx, imageName, ¤tImage) + if lookupErr == nil { + removeUnusedTunnelProxyBuildContext(imagePlan.BuildContextArchive.Source, ¤tImage, log) + return nil } - pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - return false, NoDelay + if apimachinery_errors.IsNotFound(lookupErr) { + removeUnusedTunnelProxyBuildContext(imagePlan.BuildContextArchive.Source, nil, log) + } + + return fmt.Errorf( + "create PhysicalContainerImage %q and verify whether it exists: %w", + imageName.String(), + errors.Join(createErr, lookupErr), + ) + } + + log.V(1).Info("Created shared tunnel proxy PhysicalContainerImage", "PhysicalContainerImage", imageName) + return nil +} + +func (r *ContainerNetworkTunnelProxyReconciler) scheduleTunnelProxyPhysicalContainerImageCreation( + tunnelProxyName types.NamespacedName, + log logr.Logger, +) error { + if !r.sharedImagePreparationLock.TryLock() { + return nil } - log.V(1).Info("Waiting for client proxy container to be connected to target network...") - connectionWaitErr := resiliency.RetryExponential(ctx, func() error { - inspected, inspectedErr := inspectContainer(ctx, r.config.Orchestrator, created.Id) - if inspectedErr != nil { - return inspectedErr + // Release the lock if shutdown drops the queued work before it can run. + stopUnlockOnShutdown := context.AfterFunc(r.LifetimeCtx, r.sharedImagePreparationLock.Unlock) + enqueueErr := r.workQueue.Enqueue(func(ctx context.Context) { + defer func() { + r.sharedImagePreparationLock.Unlock() + r.ScheduleReconciliation(tunnelProxyName) + }() + defer stopUnlockOnShutdown() + + imageResourceErr := r.ensureTunnelProxyPhysicalContainerImage(ctx, log) + if imageResourceErr != nil && ctx.Err() == nil { + log.Error(imageResourceErr, "Failed to ensure shared tunnel proxy PhysicalContainerImage") } - isTargetNetwork := func(n containers.InspectedContainerNetwork) bool { - return n.Name == containerNetwork.Status.NetworkName + }) + if enqueueErr != nil { + stopUnlockOnShutdown() + r.sharedImagePreparationLock.Unlock() + return fmt.Errorf("queue shared tunnel proxy PhysicalContainerImage creation: %w", enqueueErr) + } + + log.V(1).Info("Scheduled shared tunnel proxy PhysicalContainerImage creation") + return nil +} + +func removeUnusedTunnelProxyBuildContext( + buildContextSource string, + physicalImage *apiv2.PhysicalContainerImage, + log logr.Logger, +) { + if buildContextSource == "" { + return + } + if physicalImage != nil && + physicalImage.Spec.Image != nil && + physicalImage.Spec.Image.Build != nil && + physicalImage.Spec.Image.Build.ContextArchive != nil && + physicalImage.Spec.Image.Build.ContextArchive.Source == buildContextSource { + return + } + + removeErr := os.Remove(buildContextSource) + if removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + log.Error(removeErr, "Failed to remove unused tunnel proxy image build context", "Source", buildContextSource) + } +} + +func (r *ContainerNetworkTunnelProxyReconciler) updateClientProxyContainerStatus( + ctx context.Context, + tunnelProxy *apiv1.ContainerNetworkTunnelProxy, + containerNetwork *apiv1.ContainerNetwork, + pd *containerNetworkTunnelProxyData, + log logr.Logger, +) (bool, AdditionalReconciliationDelay) { + containerName := tunnelProxyPhysicalContainerName(tunnelProxy) + physicalContainer := apiv2.PhysicalContainer{} + getContainerErr := r.Get(ctx, containerName, &physicalContainer) + if apimachinery_errors.IsNotFound(getContainerErr) { + if pd.State == apiv1.ContainerNetworkTunnelProxyStateRunning { + pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed + pd.Message = fmt.Sprintf("Client proxy PhysicalContainer %q no longer exists", containerName.String()) + return false, NoDelay } - if slices.Any(inspected.Networks, isTargetNetwork) { - return nil + pd.Message = fmt.Sprintf("Waiting for client proxy PhysicalContainer %q to be created", containerName.String()) + return false, StandardDelay + } + if getContainerErr != nil { + log.Error(getContainerErr, "Failed to get client proxy PhysicalContainer") + pd.Message = fmt.Sprintf("Failed to get client proxy PhysicalContainer %q: %v", containerName.String(), getContainerErr) + return false, StandardDelay + } + + switch physicalContainer.Status.Phase { + case apiv2.PhysicalContainerPhaseRunning: + if physicalResourceReadyConditionReason(physicalContainer.Status.Conditions) == apiv2.PhysicalContainerReasonPortMappingResolutionFailed { + pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed + pd.Message = fmt.Sprintf("Client proxy PhysicalContainer failed: %s", physicalResourceStatusMessage(physicalContainer.Status.Conditions)) + return false, NoDelay } - return fmt.Errorf("client proxy container '%s' (id: %s) is not yet connected to target network '%s'", created.Name, created.Id, containerNetworkName.String()) - }) - if connectionWaitErr != nil { - log.Error(connectionWaitErr, "Error waiting for client proxy container to be connected to target network") + case apiv2.PhysicalContainerPhaseFailed, apiv2.PhysicalContainerPhaseExited: + pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed + pd.Message = fmt.Sprintf("Client proxy PhysicalContainer failed: %s", physicalResourceStatusMessage(physicalContainer.Status.Conditions)) + return false, NoDelay + case apiv2.PhysicalContainerPhasePaused: pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - pd.Message = fmt.Sprintf("Client proxy container '%s' (id: %s) failed to connect to target network '%s': %v", created.Name, created.Id, containerNetworkName.String(), connectionWaitErr) + pd.Message = fmt.Sprintf("Client proxy PhysicalContainer failed: %s", physicalResourceStatusMessage(physicalContainer.Status.Conditions)) return false, NoDelay + case apiv2.PhysicalContainerPhaseUnknown: + if physicalResourceReadyConditionReason(physicalContainer.Status.Conditions) == apiv2.PhysicalContainerReasonRuntimeContainerMissing { + pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed + pd.Message = fmt.Sprintf("Client proxy PhysicalContainer failed: %s", physicalResourceStatusMessage(physicalContainer.Status.Conditions)) + return false, NoDelay + } + pd.Message = fmt.Sprintf("Waiting for client proxy PhysicalContainer %q: %s", containerName.String(), physicalResourceStatusMessage(physicalContainer.Status.Conditions)) + return false, StandardDelay + default: + pd.Message = fmt.Sprintf("Waiting for client proxy PhysicalContainer %q: %s", containerName.String(), physicalResourceStatusMessage(physicalContainer.Status.Conditions)) + return false, StandardDelay } - containerStartCtx := ctx - if r.config.ContainerStartupTimeoutOverride > 0 { - var containerStartCtxCancel context.CancelFunc - containerStartCtx, containerStartCtxCancel = context.WithTimeout(ctx, r.config.ContainerStartupTimeoutOverride) - defer containerStartCtxCancel() + networkName := V1PhysicalContainerNetworkReferenceName(containerNetwork.UID) + physicalNetwork := apiv2.PhysicalContainerNetwork{} + getNetworkErr := r.Get(ctx, networkName, &physicalNetwork) + if apimachinery_errors.IsNotFound(getNetworkErr) { + pd.Message = fmt.Sprintf("Waiting for client proxy PhysicalContainerNetwork %q to be created", networkName.String()) + return false, StandardDelay } - started, startErr := startContainer(containerStartCtx, r.config.Orchestrator, clientProxyCtrName, created.Id, containers.StreamCommandOptions{}) - if startErr != nil { - log.Error(startErr, "Failed to start client proxy container") - pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - pd.Message = fmt.Sprintf("Failed to start client proxy container '%s' (id: %s): %v", created.Name, created.Id, startErr) - return false, NoDelay + if getNetworkErr != nil { + log.Error(getNetworkErr, "Failed to get client proxy PhysicalContainerNetwork") + pd.Message = fmt.Sprintf("Failed to get client proxy PhysicalContainerNetwork %q: %v", networkName.String(), getNetworkErr) + return false, StandardDelay + } + if physicalNetwork.Status.Phase != apiv2.PhysicalContainerNetworkPhaseReady { + pd.Message = fmt.Sprintf("Waiting for client proxy PhysicalContainerNetwork %q: %s", networkName.String(), physicalResourceStatusMessage(physicalNetwork.Status.Conditions)) + return false, StandardDelay + } + if !slices.Contains(physicalNetwork.Status.ContainerIDs, physicalContainer.Status.ContainerID) { + pd.Message = fmt.Sprintf( + "Waiting for client proxy PhysicalContainer %q to connect to PhysicalContainerNetwork %q", + containerName.String(), + networkName.String(), + ) + return false, StandardDelay } - _, controlEndpointHostPort, controlEndpointErr := getHostAddressAndPortForPorts(createOpts.Ports, dcptun.DefaultContainerProxyControlPort, started, log) - if controlEndpointErr != nil { - log.Error(controlEndpointErr, "Failed to determine control connection host port for the client proxy container") + controlPort, controlPortErr := physicalContainerHostPort(physicalContainer.Status.PortMappings, int32(dcptun.DefaultContainerProxyControlPort)) + if controlPortErr != nil { + log.Error(controlPortErr, "Failed to determine control connection host port for the client proxy PhysicalContainer") pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - pd.Message = fmt.Sprintf("Failed to determine control connection host port for the client proxy container '%s' (id: %s): %v", created.Name, created.Id, controlEndpointErr) + pd.Message = fmt.Sprintf("Failed to determine control connection host port for client proxy PhysicalContainer %q: %v", containerName.String(), controlPortErr) return false, NoDelay } - - _, dataEndpointHostPort, dataEndpointErr := getHostAddressAndPortForPorts(createOpts.Ports, dcptun.DefaultContainerProxyDataPort, started, log) - if dataEndpointErr != nil { - log.Error(dataEndpointErr, "Failed to determine data connection host port for the client proxy container") + dataPort, dataPortErr := physicalContainerHostPort(physicalContainer.Status.PortMappings, int32(dcptun.DefaultContainerProxyDataPort)) + if dataPortErr != nil { + log.Error(dataPortErr, "Failed to determine data connection host port for the client proxy PhysicalContainer") pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - pd.Message = fmt.Sprintf("Failed to determine data connection host port for the client proxy container '%s' (id: %s): %v", created.Name, created.Id, dataEndpointErr) + pd.Message = fmt.Sprintf("Failed to determine data connection host port for client proxy PhysicalContainer %q: %v", containerName.String(), dataPortErr) return false, NoDelay } - dcpproc.RunContainerWatcher(r.config.ProcessExecutor, created.Id, log) - - pd.ClientProxyControlPort = controlEndpointHostPort - pd.ClientProxyDataPort = dataEndpointHostPort - cleanupContainer = false + pd.ClientProxyContainerID = physicalContainer.Status.ContainerID + pd.ClientProxyControlPort = controlPort + pd.ClientProxyDataPort = dataPort + pd.Message = "" return true, NoDelay } +func physicalContainerHostPort(portMappings []apiv2.PhysicalContainerPortMapping, containerPort int32) (int32, error) { + for _, portMapping := range portMappings { + if portMapping.ContainerPort == containerPort && + (portMapping.Protocol == "" || portMapping.Protocol == commonapi.PortProtocolTCP) && + portMapping.HostPort > 0 { + return portMapping.HostPort, nil + } + } + + return 0, fmt.Errorf("no host port mapping exists for TCP container port %d", containerPort) +} + +func physicalResourceStatusMessage(conditions []metav1.Condition) string { + readyCondition := apimeta.FindStatusCondition(conditions, string(apiv2.ConditionReady)) + if readyCondition != nil && readyCondition.Message != "" { + return readyCondition.Message + } + return "the physical resource did not report an error message" +} + +func physicalResourceReadyConditionReason(conditions []metav1.Condition) apiv2.ConditionReason { + readyCondition := apimeta.FindStatusCondition(conditions, string(apiv2.ConditionReady)) + if readyCondition == nil { + return "" + } + return apiv2.ConditionReason(readyCondition.Reason) +} + // Starts the server proxy as an OS process. // Assumes that the client proxy container has been started and data about it has already been applied // to the passed containerNetworkTunnelProxyData instance. @@ -1340,13 +1594,17 @@ func (r *ContainerNetworkTunnelProxyReconciler) startProxyPairCleanup( return func(ctx context.Context) { r.cleanupProxyPair(ctx, pd, tunnelProxy.UID, log) - log.V(1).Info("Completed cleanup of ContainerNetworkTunnelProxy proxy pair") + log.V(1).Info("Completed cleanup attempt for ContainerNetworkTunnelProxy proxy pair") nn := tunnelProxy.NamespacedName() pdMap := r.proxyData pdMap.QueueDeferredOp(nn, func(types.NamespacedName, types.NamespacedName, *apiv1.ContainerNetworkTunnelProxy) { pdMap.Update(nn, nn, pd) }) - r.ScheduleReconciliation(nn) + reconciliationDelay := NoDelay + if !pd.cleanupCompleted { + reconciliationDelay = StandardDelay + } + r.ScheduleReconciliationWithDelay(nn, reconciliationDelay) } } @@ -1358,20 +1616,19 @@ func (r *ContainerNetworkTunnelProxyReconciler) cleanupProxyPair( proxyObjectID types.UID, log logr.Logger, ) { - if pd.ClientProxyContainerID != "" { - log.V(1).Info("Removing client proxy container...") - - cleanupCtx, cleanupCancel := context.WithTimeout(ctx, clientProxyContainerCleanupTimeout) - defer cleanupCancel() - - removeErr := r.cleanupClientContainer(cleanupCtx, pd.ClientProxyContainerID, proxyObjectID, log) - if removeErr != nil { - log.Error(removeErr, "Failed to remove client proxy container") - } else { - log.V(1).Info("Successfully removed client proxy container") - } - - // Clear the container ID regardless of whether removal was successful or not + cleanupCompleted := true + log.V(1).Info("Removing client proxy physical resources...") + physicalResourcesRemoved, removeErr := r.cleanupClientPhysicalResources(ctx, proxyObjectID) + if removeErr != nil { + log.Error(removeErr, "Failed to remove client proxy physical resources") + pd.cleanupScheduled = false + cleanupCompleted = false + } else if !physicalResourcesRemoved { + log.V(1).Info("Client proxy physical resource deletion is still in progress") + pd.cleanupScheduled = false + cleanupCompleted = false + } else { + log.V(1).Info("Successfully removed client proxy physical resources") pd.ClientProxyContainerID = "" } @@ -1386,12 +1643,13 @@ func (r *ContainerNetworkTunnelProxyReconciler) cleanupProxyPair( stopErr := r.config.ProcessExecutor.StopProcess(process.NewHandle(pid, startTime)) if stopErr != nil && !errors.Is(stopErr, process.ErrorProcessNotFound) { log.Error(stopErr, "Failed to stop server proxy process") + pd.cleanupScheduled = false + cleanupCompleted = false } else { log.V(1).Info("Successfully stopped server proxy process") + pd.ServerProxyProcessID = nil + pd.ServerProxyStartupTimestamp = metav1.MicroTime{} // Zero value } - - pd.ServerProxyProcessID = nil - pd.ServerProxyStartupTimestamp = metav1.MicroTime{} // Zero value } if pd.serverStdout != nil { @@ -1406,32 +1664,27 @@ func (r *ContainerNetworkTunnelProxyReconciler) cleanupProxyPair( } pd.serverStderr = nil } + + pd.cleanupCompleted = cleanupCompleted } -func (r *ContainerNetworkTunnelProxyReconciler) cleanupClientContainer( +func (r *ContainerNetworkTunnelProxyReconciler) cleanupClientPhysicalResources( ctx context.Context, - containerID string, proxyObjectID types.UID, - log logr.Logger, -) error { - removeCtx, removeCtxCancel := context.WithTimeout(ctx, clientProxyContainerCleanupTimeout) - defer removeCtxCancel() - - ctrRmErr := removeContainer(removeCtx, r.config.Orchestrator, containerID) - - // Best effort - netConnErr := r.Client.DeleteAllOf( - removeCtx, - &apiv1.ContainerNetworkConnection{}, - ctrl_client.PropagationPolicy(metav1.DeletePropagationBackground), - ctrl_client.MatchingLabels{ - ContainerIdLabel: MakeValidLabelValue(containerID), - }, - ) - - r.ContainerWatcher.ReleaseContainerWatchForResource(proxyObjectID, log) +) (bool, error) { + containerName := tunnelProxyPhysicalContainerNameForUID(proxyObjectID) + physicalContainer := &apiv2.PhysicalContainer{ + ObjectMeta: metav1.ObjectMeta{Name: containerName.Name, Namespace: containerName.Namespace}, + } + deleteErr := r.Client.Delete(ctx, physicalContainer) + if apimachinery_errors.IsNotFound(deleteErr) { + return true, nil + } + if deleteErr != nil { + return false, fmt.Errorf("delete PhysicalContainer %q: %w", containerName.String(), deleteErr) + } - return errors.Join(ctrRmErr, netConnErr) + return false, nil } func (r *ContainerNetworkTunnelProxyReconciler) onServerProcessExit( @@ -1462,8 +1715,8 @@ func (r *ContainerNetworkTunnelProxyReconciler) onServerProcessExit( if pd == nil { return // ContainerNetworkTunnelProxy object has been deleted, nothing to do } - if pd.cleanupScheduled { - return // We are cleaning up and just got a callback reporting server process termination, nothing to do + if pd.cleanupScheduled || pd.State == apiv1.ContainerNetworkTunnelProxyStateFailed { + return // Cleanup or another failure already owns the proxy's terminal status. } // Server proxy process exited unexpectedly, so we need to mark the proxy as failed, which will trigger the cleanup. @@ -1480,35 +1733,6 @@ func (r *ContainerNetworkTunnelProxyReconciler) onServerProcessExit( r.ScheduleReconciliation(pName) } -func (r *ContainerNetworkTunnelProxyReconciler) processContainerEvent(em containers.EventMessage) { - switch em.Action { - // Any event that means the container is no longer running is interesting and means the proxy should be marked as failed. - case containers.EventActionDestroy, containers.EventActionDie, containers.EventActionDied, containers.EventActionKill, containers.EventActionOom, containers.EventActionStop, containers.EventActionPrune: - containerID := em.Actor.ID - - r.proxyData.Range(func(pName types.NamespacedName, _ types.NamespacedName, pd *containerNetworkTunnelProxyData) bool { - if pd.cleanupScheduled { - return true // This proxy is cleaning up so container stop events are expected - } - - if pd.ClientProxyContainerID == containerID { - r.Log.Error(fmt.Errorf("client proxy container stopped unexpectedly"), "Container network proxy has failed", "ContainerID", containerID) - pdMap := r.proxyData - pdMap.QueueDeferredOp(pName, func(types.NamespacedName, types.NamespacedName, *apiv1.ContainerNetworkTunnelProxy) { - _, pd = pdMap.BorrowByNamespacedName(pName) - pd.State = apiv1.ContainerNetworkTunnelProxyStateFailed - pd.Message = fmt.Sprintf("Client proxy container (id: %s) stopped unexpectedly", containerID) - pdMap.Update(pName, pName, pd) - }) - r.ScheduleReconciliation(pName) - return false // At most one proxy can be using the container, so we can stop iterating - } - - return true // Continue iteration - }) - } -} - // // ENDPOINT OWNER (CREATOR) METHODS // diff --git a/controllers/container_network_tunnel_proxy_data.go b/controllers/container_network_tunnel_proxy_data.go index 90471e5f..35887b7d 100644 --- a/controllers/container_network_tunnel_proxy_data.go +++ b/controllers/container_network_tunnel_proxy_data.go @@ -71,6 +71,9 @@ type containerNetworkTunnelProxyData struct { // can take a while, so we do it asynchronously. cleanupScheduled bool + // Whether cleanup completed successfully. + cleanupCompleted bool + // Standard output file for the server proxy process. // Note: this is a file descriptor, and is not "cloned" when Clone() is called. serverStdout *os.File @@ -103,6 +106,7 @@ func (tpd *containerNetworkTunnelProxyData) Clone() *containerNetworkTunnelProxy ContainerNetworkTunnelProxyStatus: *tpd.ContainerNetworkTunnelProxyStatus.DeepCopy(), startupScheduled: tpd.startupScheduled, cleanupScheduled: tpd.cleanupScheduled, + cleanupCompleted: tpd.cleanupCompleted, serverStdout: tpd.serverStdout, serverStderr: tpd.serverStderr, tunnelExtra: maps.Map[string, tunnelExtraData, tunnelExtraData](tpd.tunnelExtra, tunnelExtraData.Clone), @@ -194,6 +198,11 @@ func (tpd *containerNetworkTunnelProxyData) UpdateFrom(other *containerNetworkTu updated = true } + if tpd.cleanupCompleted != other.cleanupCompleted { + tpd.cleanupCompleted = other.cleanupCompleted + updated = true + } + if other.serverStdout != nil && tpd.serverStdout != other.serverStdout { tpd.serverStdout = other.serverStdout updated = true diff --git a/controllers/controller_common.go b/controllers/controller_common.go index d2778640..3a64a7d4 100644 --- a/controllers/controller_common.go +++ b/controllers/controller_common.go @@ -9,6 +9,7 @@ import ( "context" "crypto/rand" "encoding/base32" + "errors" "fmt" "hash/fnv" mathrand "math/rand" @@ -27,6 +28,7 @@ import ( ctrl_config "sigs.k8s.io/controller-runtime/pkg/config" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + apiv1 "github.com/microsoft/dcp/api/v1" apiv2 "github.com/microsoft/dcp/api/v2" "github.com/microsoft/dcp/pkg/commonapi" "github.com/microsoft/dcp/pkg/osutil" @@ -53,6 +55,13 @@ const ( CreatorProcessStartTimeLabel = "com.microsoft.developer.usvc-dev.creatorProcessStartTime" ContainerIdLabel = "com.microsoft.developer.usvc-dev.containerId" + // V1PhysicalResourcesNamespaceName is the shared V2 namespace for physical resources created by V1 controllers. + V1PhysicalResourcesNamespaceName = "v1-compatibility" + // V1TunnelProxyPhysicalContainerImageName is the shared tunnel proxy image resource created by the V1 tunnel controller. + V1TunnelProxyPhysicalContainerImageName = "tunnel-proxy" + // V1ContainerNetworkNameAnnotation identifies the V1 ContainerNetwork mirrored by a V2 physical network reference. + V1ContainerNetworkNameAnnotation = "usvc-dev.developer.microsoft.com/v1-container-network-name" + MaxConcurrentReconciles = 6 numPostfixBytes = 6 @@ -165,6 +174,75 @@ func deleteFinalizer(obj metav1.Object, finalizer string, log logr.Logger) objec return metadataChanged } +// EnsureV1PhysicalResourcesNamespace creates the shared V2 namespace used by V1 controllers. +func EnsureV1PhysicalResourcesNamespace(ctx context.Context, client ctrl_client.Client) error { + namespace := &apiv2.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: V1PhysicalResourcesNamespaceName, + }, + } + createErr := client.Create(ctx, namespace) + if createErr != nil && !apierrors.IsAlreadyExists(createErr) { + return fmt.Errorf("create V1 physical resources namespace: %w", createErr) + } + + return nil +} + +// V1PhysicalContainerNetworkReferenceName returns the shared V2 physical network reference name for a V1 ContainerNetwork. +func V1PhysicalContainerNetworkReferenceName(networkUID types.UID) types.NamespacedName { + return types.NamespacedName{ + Namespace: V1PhysicalResourcesNamespaceName, + Name: fmt.Sprintf("container-network-%s", networkUID), + } +} + +// EnsureV1PhysicalContainerNetworkReference ensures a non-owning V2 reference to a running V1 ContainerNetwork. +func EnsureV1PhysicalContainerNetworkReference( + ctx context.Context, + client ctrl_client.Client, + network *apiv1.ContainerNetwork, +) error { + if network.UID == "" { + return errors.New("v1 ContainerNetwork UID is missing") + } + if network.Status.ID == "" { + return errors.New("v1 ContainerNetwork runtime ID is missing") + } + + namespaceErr := EnsureV1PhysicalResourcesNamespace(ctx, client) + if namespaceErr != nil { + return namespaceErr + } + + resourceName := V1PhysicalContainerNetworkReferenceName(network.UID) + physicalNetwork := apiv2.PhysicalContainerNetwork{} + getErr := client.Get(ctx, resourceName, &physicalNetwork) + if apierrors.IsNotFound(getErr) { + physicalNetwork = apiv2.PhysicalContainerNetwork{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName.Name, + Namespace: resourceName.Namespace, + Annotations: map[string]string{ + V1ContainerNetworkNameAnnotation: network.Name, + }, + }, + Spec: apiv2.PhysicalContainerNetworkSpec{ + NetworkID: network.Status.ID, + }, + } + createErr := client.Create(ctx, &physicalNetwork) + if createErr != nil && !apierrors.IsAlreadyExists(createErr) { + return fmt.Errorf("create v2 PhysicalContainerNetwork reference %q: %w", resourceName.String(), createErr) + } + return nil + } + if getErr != nil { + return fmt.Errorf("get v2 PhysicalContainerNetwork reference %q: %w", resourceName.String(), getErr) + } + return nil +} + // checkNamespaceReady reports whether a namespace permits a V2 resource to perform runtime work. // Deletion paths bypass this check so cleanup can still finish. func checkNamespaceReady( diff --git a/controllers/controller_common_test.go b/controllers/controller_common_test.go index 8650230f..2936e2b5 100644 --- a/controllers/controller_common_test.go +++ b/controllers/controller_common_test.go @@ -51,6 +51,50 @@ func TestMapNamespaceToReconcileRequests(t *testing.T) { }, names) } +func TestEnsureV1PhysicalResourcesNamespace(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, apiv2.AddToScheme(scheme)) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + require.NoError(t, EnsureV1PhysicalResourcesNamespace(context.Background(), client)) + require.NoError(t, EnsureV1PhysicalResourcesNamespace(context.Background(), client)) + + namespace := apiv2.Namespace{} + getErr := client.Get(context.Background(), types.NamespacedName{Name: V1PhysicalResourcesNamespaceName}, &namespace) + require.NoError(t, getErr) + require.Equal(t, V1PhysicalResourcesNamespaceName, namespace.Name) +} + +func TestEnsureV1PhysicalContainerNetworkReference(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, apiv1.AddToScheme(scheme)) + require.NoError(t, apiv2.AddToScheme(scheme)) + client := fake.NewClientBuilder().WithScheme(scheme).Build() + network := &apiv1.ContainerNetwork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-network", + UID: types.UID("source-network-uid"), + }, + Status: apiv1.ContainerNetworkStatus{ + ID: "first-network-id", + }, + } + + ensureErr := EnsureV1PhysicalContainerNetworkReference(context.Background(), client, network) + require.NoError(t, ensureErr) + + resourceName := V1PhysicalContainerNetworkReferenceName(network.UID) + physicalNetwork := apiv2.PhysicalContainerNetwork{} + require.NoError(t, client.Get(context.Background(), resourceName, &physicalNetwork)) + require.Equal(t, network.Status.ID, physicalNetwork.Spec.NetworkID) + require.Nil(t, physicalNetwork.Spec.Network) + require.Equal(t, network.Name, physicalNetwork.Annotations[V1ContainerNetworkNameAnnotation]) +} + // Verifies that callWithRetryAndVerification() can be stopped from retrying by returning a permanent error func TestCallWithRetryAndVerificationPermanentError(t *testing.T) { t.Parallel() diff --git a/controllers/namespace_controller.go b/controllers/namespace_controller.go index 71d24868..8afa9245 100644 --- a/controllers/namespace_controller.go +++ b/controllers/namespace_controller.go @@ -47,11 +47,12 @@ type namespaceCleanupResult struct { } var namespaceCleanupResourceHandlers = map[schema.GroupVersionResource]namespaceCleanupResourceHandler{ - (&apiv2.PhysicalProcess{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalProcesses, - (&apiv2.PhysicalContainer{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalContainers, - (&apiv2.PhysicalContainerImage{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalContainerImages, - (&apiv2.PhysicalContainerNetwork{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalContainerNetworks, - (&apiv2.PhysicalContainerVolume{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalContainerVolumes, + (&apiv2.PhysicalProcess{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalProcesses, + (&apiv2.PhysicalContainer{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalContainers, + (&apiv2.PhysicalContainerImage{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalContainerImages, + (&apiv2.PhysicalContainerNetwork{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalContainerNetworks, + (&apiv2.PhysicalContainerNetworkConnection{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalContainerNetworkConnections, + (&apiv2.PhysicalContainerVolume{}).GetGroupVersionResource(): (*NamespaceReconciler).cleanupPhysicalContainerVolumes, } func (r *NamespaceReconciler) cleanupPhysicalProcesses(ctx context.Context, namespace *apiv2.Namespace, log logr.Logger) (int, error) { @@ -81,6 +82,37 @@ func (r *NamespaceReconciler) cleanupPhysicalProcesses(ctx context.Context, name return len(physicalProcesses.Items), nil } +func (r *NamespaceReconciler) cleanupPhysicalContainerNetworkConnections( + ctx context.Context, + namespace *apiv2.Namespace, + log logr.Logger, +) (int, error) { + connections := apiv2.PhysicalContainerNetworkConnectionList{} + listErr := r.NoCacheClient.List(ctx, &connections, ctrl_client.InNamespace(namespace.Name)) + if listErr != nil { + return 0, fmt.Errorf("failed to list PhysicalContainerNetworkConnections in namespace %q: %w", namespace.Name, listErr) + } + + deleteErrors := slices.MapConcurrent[error](connections.Items, func(connection apiv2.PhysicalContainerNetworkConnection) error { + if connection.DeletionTimestamp != nil && !connection.DeletionTimestamp.IsZero() { + return nil + } + + log.V(1).Info("Deleting PhysicalContainerNetworkConnection during namespace cleanup", "Namespace", namespace.Name, "PhysicalContainerNetworkConnection", connection.Name) + deleteConnectionErr := r.Client.Delete(ctx, &connection) + if deleteConnectionErr != nil && !apierrors.IsNotFound(deleteConnectionErr) { + return fmt.Errorf("failed to delete PhysicalContainerNetworkConnection %q in namespace %q: %w", connection.Name, namespace.Name, deleteConnectionErr) + } + return nil + }, namespaceCleanupMaxConcurrentDeletes) + deleteErr := errors.Join(deleteErrors...) + if deleteErr != nil { + return 0, deleteErr + } + + return len(connections.Items), nil +} + type NamespaceReconciler struct { *ReconcilerBase[apiv2.Namespace, *apiv2.Namespace] } diff --git a/controllers/namespace_controller_test.go b/controllers/namespace_controller_test.go index a0d7d1c8..e0e2f02d 100644 --- a/controllers/namespace_controller_test.go +++ b/controllers/namespace_controller_test.go @@ -34,9 +34,9 @@ func TestNamespaceCleanupProcessesReadyResourceKindsConcurrently(t *testing.T) { }) processStarted := make(chan struct{}) - containerStarted := make(chan struct{}) + connectionStarted := make(chan struct{}) processGVR := (&apiv2.PhysicalProcess{}).GetGroupVersionResource() - containerGVR := (&apiv2.PhysicalContainer{}).GetGroupVersionResource() + connectionGVR := (&apiv2.PhysicalContainerNetworkConnection{}).GetGroupVersionResource() namespaceCleanupResourceHandlers[processGVR] = func( _ *NamespaceReconciler, ctx context.Context, @@ -45,19 +45,19 @@ func TestNamespaceCleanupProcessesReadyResourceKindsConcurrently(t *testing.T) { ) (int, error) { close(processStarted) select { - case <-containerStarted: + case <-connectionStarted: return 1, nil case <-ctx.Done(): return 0, ctx.Err() } } - namespaceCleanupResourceHandlers[containerGVR] = func( + namespaceCleanupResourceHandlers[connectionGVR] = func( _ *NamespaceReconciler, ctx context.Context, _ *apiv2.Namespace, _ logr.Logger, ) (int, error) { - close(containerStarted) + close(connectionStarted) select { case <-processStarted: return 1, nil @@ -71,5 +71,5 @@ func TestNamespaceCleanupProcessesReadyResourceKindsConcurrently(t *testing.T) { pending, cleanupErr := reconciler.cleanupNamespace(ctx, namespace, logr.Discard()) require.NoError(t, cleanupErr) - require.Equal(t, "1 physicalprocesses and 1 physicalcontainers", pending) + require.Equal(t, "1 physicalprocesses and 1 physicalcontainernetworkconnections", pending) } diff --git a/controllers/physical_container_controller.go b/controllers/physical_container_controller.go index 40337807..7570392e 100644 --- a/controllers/physical_container_controller.go +++ b/controllers/physical_container_controller.go @@ -30,13 +30,18 @@ import ( apiv2 "github.com/microsoft/dcp/api/v2" "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/internal/dcpproc" "github.com/microsoft/dcp/pkg/commonapi" "github.com/microsoft/dcp/pkg/osutil" + "github.com/microsoft/dcp/pkg/process" "github.com/microsoft/dcp/pkg/resiliency" "github.com/microsoft/dcp/pkg/slices" ) -const physicalContainerImageRefField = ".spec.imageRef" +const ( + physicalContainerImageRefField = ".spec.imageRef" + physicalContainerNetworkRefField = ".spec.networks.name" +) var ( physicalContainerFinalizer string = fmt.Sprintf("%s/physicalcontainer-reconciler", apiv2.GroupVersion.Group) @@ -45,6 +50,7 @@ var ( physicalContainerStateNamespace: handlePhysicalContainerNamespace, physicalContainerStateResolve: handlePhysicalContainerResolve, physicalContainerStateImage: handlePhysicalContainerImage, + physicalContainerStateNetworks: handlePhysicalContainerNetworks, physicalContainerStateCreate: handlePhysicalContainerCreate, physicalContainerStateReplace: handlePhysicalContainerCreateFailure, physicalContainerStateCopyFiles: handlePhysicalContainerCopyFiles, @@ -69,9 +75,10 @@ type PhysicalContainerReconciler struct { *ReconcilerBase[apiv2.PhysicalContainer, *apiv2.PhysicalContainer] *ContainerWatcher[apiv2.PhysicalContainer] - orchestrator containers.ContainerOrchestrator - containerData *ObjectStateMap[physicalContainerDataStateKey, physicalContainerData, *physicalContainerData, *apiv2.PhysicalContainer] - operationQueue *resiliency.WorkQueue + orchestrator containers.ContainerOrchestrator + processExecutor process.Executor + containerData *ObjectStateMap[physicalContainerDataStateKey, physicalContainerData, *physicalContainerData, *apiv2.PhysicalContainer] + operationQueue *resiliency.WorkQueue } func NewPhysicalContainerReconciler( @@ -80,12 +87,14 @@ func NewPhysicalContainerReconciler( noCacheClient ctrl_client.Reader, log logr.Logger, orchestrator containers.ContainerOrchestrator, + processExecutor process.Executor, ) *PhysicalContainerReconciler { lock := &sync.Mutex{} reconciler := PhysicalContainerReconciler{ ReconcilerBase: NewReconcilerBase[apiv2.PhysicalContainer](client, noCacheClient, log, lifetimeCtx), ContainerWatcher: NewContainerWatcher[apiv2.PhysicalContainer](orchestrator, lock, lifetimeCtx), orchestrator: orchestrator, + processExecutor: processExecutor, containerData: NewObjectStateMap[physicalContainerDataStateKey, physicalContainerData, *physicalContainerData, *apiv2.PhysicalContainer](), operationQueue: resiliency.NewWorkQueue(lifetimeCtx, MaxConcurrentReconciles), } @@ -94,7 +103,8 @@ func NewPhysicalContainerReconciler( } func (r *PhysicalContainerReconciler) SetupWithManager(mgr ctrl.Manager, name string) error { - if err := mgr.GetFieldIndexer().IndexField(context.Background(), &apiv2.PhysicalContainer{}, physicalContainerImageRefField, func(rawObj ctrl_client.Object) []string { + indexer := mgr.GetFieldIndexer() + if err := indexer.IndexField(context.Background(), &apiv2.PhysicalContainer{}, physicalContainerImageRefField, func(rawObj ctrl_client.Object) []string { container := rawObj.(*apiv2.PhysicalContainer) if container.Spec.Container == nil || container.Spec.Container.ImageRef == "" { return nil @@ -106,16 +116,64 @@ func (r *PhysicalContainerReconciler) SetupWithManager(mgr ctrl.Manager, name st return err } + if err := indexer.IndexField(context.Background(), &apiv2.PhysicalContainer{}, physicalContainerNetworkRefField, func(rawObj ctrl_client.Object) []string { + container := rawObj.(*apiv2.PhysicalContainer) + if container.Spec.Container == nil || len(container.Spec.Container.Networks) == 0 { + return nil + } + + networkNames := make([]string, 0, len(container.Spec.Container.Networks)) + for i := range container.Spec.Container.Networks { + if container.Spec.Container.Networks[i].Name != "" { + networkNames = append(networkNames, container.Spec.Container.Networks[i].Name) + } + } + return networkNames + }); err != nil { + r.Log.Error(err, "Failed to create network reference index for PhysicalContainer", "IndexField", physicalContainerNetworkRefField) + return err + } + + if err := indexer.IndexField(context.Background(), &apiv2.PhysicalContainerNetworkConnection{}, ownerKey, func(rawObj ctrl_client.Object) []string { + connection := rawObj.(*apiv2.PhysicalContainerNetworkConnection) + owner := metav1.GetControllerOf(connection) + if owner == nil || owner.APIVersion != apiv2.GroupVersion.String() || owner.Kind != "PhysicalContainer" { + return nil + } + return []string{owner.Name} + }); err != nil { + r.Log.Error(err, "Failed to create owner index for PhysicalContainerNetworkConnection", "IndexField", ownerKey) + return err + } + return ctrl.NewControllerManagedBy(mgr). WithOptions(controller.Options{MaxConcurrentReconciles: MaxConcurrentReconciles}). For(&apiv2.PhysicalContainer{}). + Owns(&apiv2.PhysicalContainerNetworkConnection{}). Watches(&apiv2.PhysicalContainerImage{}, handler.EnqueueRequestsFromMapFunc(r.requestReconcileForImage), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Watches(&apiv2.PhysicalContainerNetwork{}, handler.EnqueueRequestsFromMapFunc(r.requestReconcileForNetwork), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). Watches(&apiv2.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.mapNamespaceToReconcileRequests(&apiv2.PhysicalContainerList{})), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). WatchesRawSource(r.GetReconciliationEventSource()). Named(name). Complete(r) } +func (r *PhysicalContainerReconciler) requestReconcileForNetwork(ctx context.Context, obj ctrl_client.Object) []reconcile.Request { + network := obj.(*apiv2.PhysicalContainerNetwork) + var containerList apiv2.PhysicalContainerList + listErr := r.List(ctx, &containerList, ctrl_client.InNamespace(network.Namespace), ctrl_client.MatchingFields{physicalContainerNetworkRefField: network.Name}) + if listErr != nil { + r.Log.Error(listErr, "Failed to list PhysicalContainers referencing PhysicalContainerNetwork", "Network", network.NamespacedName()) + return nil + } + + requests := make([]reconcile.Request, 0, len(containerList.Items)) + for i := range containerList.Items { + requests = append(requests, reconcile.Request{NamespacedName: containerList.Items[i].NamespacedName()}) + } + return requests +} + func (r *PhysicalContainerReconciler) requestReconcileForImage(ctx context.Context, obj ctrl_client.Object) []reconcile.Request { image := obj.(*apiv2.PhysicalContainerImage) var containerList apiv2.PhysicalContainerList @@ -322,7 +380,30 @@ func handlePhysicalContainerImage( } data.image = image - return imageChange | reconciler.schedulePhysicalContainerCreate(container, data, log) + data.state = physicalContainerStateNetworks + data.progress = physicalResourceProgressInProgress + data.failureMessage = "" + return imageChange | handlePhysicalContainerNetworks(ctx, reconciler, container, data.state, data, log) +} + +func handlePhysicalContainerNetworks( + ctx context.Context, + reconciler *PhysicalContainerReconciler, + container *apiv2.PhysicalContainer, + _ physicalContainerState, + data *physicalContainerData, + log logr.Logger, +) objectChange { + networksReady, networks, networkProgress, networkMessage := reconciler.resolvePhysicalContainerNetworks(ctx, container, log) + if !networksReady { + data.state = physicalContainerStateNetworks + data.progress = networkProgress + data.failureMessage = networkMessage + return noChange + } + + data.networks = networks + return reconciler.schedulePhysicalContainerCreate(container, data, log) } func handlePhysicalContainerCreate( @@ -402,7 +483,12 @@ func handlePhysicalContainerRuntime( data.state = physicalContainerStateRuntime data.progress = physicalResourceProgressMissing data.failureMessage = "" - return noChange + connectionChange, connectionErr := reconciler.ensurePhysicalContainerNetworkConnections(ctx, container, true, log) + if connectionErr != nil { + log.Error(connectionErr, "Failed to remove PhysicalContainerNetworkConnections for missing runtime container") + connectionChange |= additionalReconciliationNeeded + } + return connectionChange } if inspectErr != nil { log.Error(inspectErr, "Failed to inspect runtime container", "ContainerID", containerID) @@ -412,11 +498,16 @@ func handlePhysicalContainerRuntime( return additionalReconciliationNeeded } + connectionChange, connectionErr := reconciler.ensurePhysicalContainerNetworkConnections(ctx, container, container.Spec.Stop, log) + if connectionErr != nil { + log.Error(connectionErr, "Failed to ensure PhysicalContainerNetworkConnections") + connectionChange |= additionalReconciliationNeeded + } if container.Spec.Stop { - return reconciler.stopPhysicalContainer(ctx, container, data, inspectedContainer, log) + return connectionChange | reconciler.stopPhysicalContainer(ctx, container, data, inspectedContainer, log) } - return reconciler.applyInspectedPhysicalContainerStatus(container, data, inspectedContainer, log) + return connectionChange | reconciler.applyInspectedPhysicalContainerStatus(container, data, inspectedContainer, log) } // Stops the runtime container when it is still active and records the resulting state. @@ -503,16 +594,21 @@ func handlePhysicalContainerFilesCreated( } func handlePhysicalContainerOperationFailed( - _ context.Context, - _ *PhysicalContainerReconciler, - _ *apiv2.PhysicalContainer, + ctx context.Context, + reconciler *PhysicalContainerReconciler, + container *apiv2.PhysicalContainer, _ physicalContainerState, data *physicalContainerData, log logr.Logger, ) objectChange { log.V(1).Info("Physical container operation failed; saving container status", "Message", data.failureMessage) + connectionChange, connectionErr := reconciler.ensurePhysicalContainerNetworkConnections(ctx, container, true, log) + if connectionErr != nil { + log.Error(connectionErr, "Failed to remove PhysicalContainerNetworkConnections after container operation failure") + return connectionChange | additionalReconciliationNeeded + } // The failure is terminal: spec is immutable, so no further reconciliation can make progress. - return noChange + return connectionChange } func handlePhysicalContainerCreateFailure( @@ -657,6 +753,38 @@ func (r *PhysicalContainerReconciler) resolvePhysicalContainerImage( return true, image.Status.ImageID, physicalResourceProgressCompleted, "", setValue(&container.Status.Image, image.Status.ImageID) } +func (r *PhysicalContainerReconciler) resolvePhysicalContainerNetworks( + ctx context.Context, + container *apiv2.PhysicalContainer, + log logr.Logger, +) (bool, []containers.CreateContainerNetworkOptions, physicalResourceProgress, string) { + containerConfig := container.Spec.Container + networks := make([]containers.CreateContainerNetworkOptions, 0, len(containerConfig.Networks)) + for i := range containerConfig.Networks { + networkConfig := &containerConfig.Networks[i] + network := apiv2.PhysicalContainerNetwork{} + networkName := types.NamespacedName{Namespace: container.Namespace, Name: networkConfig.Name} + getErr := r.Client.Get(ctx, networkName, &network) + if apierrors.IsNotFound(getErr) { + return false, nil, physicalResourceProgressNotFound, fmt.Sprintf("PhysicalContainerNetwork %q does not exist.", networkConfig.Name) + } + if getErr != nil { + log.Error(getErr, "Failed to get PhysicalContainerNetwork", "NetworkRef", networkConfig.Name) + return false, nil, physicalResourceProgressRetryPending, fmt.Sprintf("Failed to get PhysicalContainerNetwork %q: %v", networkConfig.Name, getErr) + } + if network.Status.Phase != apiv2.PhysicalContainerNetworkPhaseReady || network.Status.NetworkID == "" { + return false, nil, physicalResourceProgressNotReady, fmt.Sprintf("PhysicalContainerNetwork %q is not ready.", networkConfig.Name) + } + + networks = append(networks, containers.CreateContainerNetworkOptions{ + Name: network.Status.NetworkID, + Aliases: append([]string{}, networkConfig.Aliases...), + }) + } + + return true, networks, physicalResourceProgressCompleted, "" +} + func (r *PhysicalContainerReconciler) schedulePhysicalContainerCreate( container *apiv2.PhysicalContainer, currentData *physicalContainerData, @@ -719,7 +847,7 @@ func (r *PhysicalContainerReconciler) createPhysicalContainer( Command: containerConfig.Command, VolumeMounts: physicalVolumeMountsToCreateContainerVolumeMounts(containerConfig.VolumeMounts), Ports: physicalPortsToCreateContainerPorts(containerConfig.Ports), - Networks: physicalNetworksToCreateContainerNetworks(containerConfig.Networks), + Networks: data.networks, Env: containerConfig.Env, Labels: physicalContainerCreationLabels(container, log), }) @@ -750,11 +878,29 @@ func (r *PhysicalContainerReconciler) createPhysicalContainer( data.progress = physicalContainerOperationCompleted data.failureMessage = "" data.retryAfter = time.Time{} + r.runPhysicalContainerLifecycleMonitor(container, containerID, log) } r.queuePhysicalContainerDataResult(container, stateKey, data) } +// Starts a container monitor process that removes the runtime container if this DCP instance terminates unexpectedly. +// Containers the resource does not own past its own lifetime (RetainRuntimeContainer) are left alone. +// Failures are logged but not surfaced, because the monitor is a best-effort reliability enhancement; +// the container harvester reclaims orphaned containers in a later session. +func (r *PhysicalContainerReconciler) runPhysicalContainerLifecycleMonitor(container *apiv2.PhysicalContainer, containerID string, log logr.Logger) { + if container.Spec.Container == nil || container.Spec.Container.RetainRuntimeContainer || containerID == "" { + return + } + + if r.processExecutor == nil { + log.Error(errors.New("process executor is not configured"), "Could not start PhysicalContainer cleanup monitor") + return + } + + dcpproc.RunContainerWatcher(r.processExecutor, containerID, log) +} + func (r *PhysicalContainerReconciler) removePhysicalContainerForReplacement(ctx context.Context, containerName string, log logr.Logger) error { inspectedContainer, inspectErr := r.inspectPhysicalContainer(ctx, containerName) if errors.Is(inspectErr, containers.ErrNotFound) { @@ -994,6 +1140,106 @@ func physicalContainerNeedsStopping(inspectedContainer *containers.InspectedCont inspectedContainer.Status == containers.ContainerStatusRestarting } +func physicalContainerNetworkConnectionName(container *apiv2.PhysicalContainer, networkIndex int) string { + return fmt.Sprintf("pcnc-%s-%d", container.UID, networkIndex) +} + +func (r *PhysicalContainerReconciler) ensurePhysicalContainerNetworkConnections( + ctx context.Context, + container *apiv2.PhysicalContainer, + removeAll bool, + log logr.Logger, +) (objectChange, error) { + if container.UID == "" { + return noChange, errors.New("physical container UID is missing") + } + + connections := apiv2.PhysicalContainerNetworkConnectionList{} + listErr := r.List( + ctx, + &connections, + ctrl_client.InNamespace(container.Namespace), + ctrl_client.MatchingFields{ownerKey: container.Name}, + ) + if listErr != nil { + return noChange, fmt.Errorf("list child PhysicalContainerNetworkConnections: %w", listErr) + } + + desiredConnections := make(map[string]apiv2.PhysicalContainerNetworkConnectionSpec) + if !removeAll && container.Spec.Container != nil { + desiredConnections = make(map[string]apiv2.PhysicalContainerNetworkConnectionSpec, len(container.Spec.Container.Networks)) + for i := range container.Spec.Container.Networks { + network := &container.Spec.Container.Networks[i] + desiredConnections[physicalContainerNetworkConnectionName(container, i)] = apiv2.PhysicalContainerNetworkConnectionSpec{ + ContainerRef: container.Name, + NetworkRef: network.Name, + Aliases: append([]string{}, network.Aliases...), + } + } + } + + change := noChange + var resultErr error + existingNames := make(map[string]struct{}, len(connections.Items)) + for i := range connections.Items { + connection := &connections.Items[i] + if _, expected := desiredConnections[connection.Name]; expected { + existingNames[connection.Name] = struct{}{} + continue + } + + deleteErr := r.Delete(ctx, connection, ctrl_client.PropagationPolicy(metav1.DeletePropagationBackground)) + if ctrl_client.IgnoreNotFound(deleteErr) != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("delete PhysicalContainerNetworkConnection %q: %w", connection.NamespacedName(), deleteErr)) + continue + } + log.Info("Removed a PhysicalContainerNetworkConnection", "PhysicalContainerNetworkConnection", connection.NamespacedName()) + change |= additionalReconciliationNeeded + } + + for connectionName, connectionSpec := range desiredConnections { + if _, exists := existingNames[connectionName]; exists { + continue + } + + connection := apiv2.PhysicalContainerNetworkConnection{ + ObjectMeta: metav1.ObjectMeta{ + Name: connectionName, + Namespace: container.Namespace, + }, + Spec: connectionSpec, + } + ownerErr := ctrl.SetControllerReference(container, &connection, r.Scheme()) + if ownerErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("set PhysicalContainerNetworkConnection %q owner: %w", connection.NamespacedName(), ownerErr)) + continue + } + + createErr := r.Create(ctx, &connection) + if apierrors.IsAlreadyExists(createErr) { + existingConnection := apiv2.PhysicalContainerNetworkConnection{} + getErr := r.NoCacheClient.Get(ctx, connection.NamespacedName(), &existingConnection) + if getErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("get existing PhysicalContainerNetworkConnection %q: %w", connection.NamespacedName(), getErr)) + continue + } + if !metav1.IsControlledBy(&existingConnection, container) || + !existingConnection.Spec.Equal(connection.Spec) { + resultErr = errors.Join(resultErr, fmt.Errorf("physical container network connection %q already exists with a different owner or spec", connection.NamespacedName())) + } + continue + } + if createErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("create PhysicalContainerNetworkConnection %q: %w", connection.NamespacedName(), createErr)) + continue + } + log.Info("Added a PhysicalContainerNetworkConnection", "PhysicalContainerNetworkConnection", connection.NamespacedName()) + change |= additionalReconciliationNeeded + } + + return change, resultErr +} + func (r *PhysicalContainerReconciler) handleDeletionRequest( ctx context.Context, container *apiv2.PhysicalContainer, @@ -1005,6 +1251,12 @@ func (r *PhysicalContainerReconciler) handleDeletionRequest( return additionalReconciliationNeeded } + connectionChange, connectionErr := r.ensurePhysicalContainerNetworkConnections(ctx, container, true, log) + if connectionErr != nil { + log.Error(connectionErr, "Failed to remove PhysicalContainerNetworkConnections") + return connectionChange | additionalReconciliationNeeded + } + containerID := data.containerID if containerID == "" { containerID = container.Spec.ContainerID @@ -1021,12 +1273,12 @@ func (r *PhysicalContainerReconciler) handleDeletionRequest( data.progress = physicalResourceProgressRetryPending data.containerID = containerID data.failureMessage = fmt.Sprintf("Failed to remove runtime container: %v", removeErr) - return setValue(&container.Status.ContainerID, containerID) | additionalReconciliationNeeded + return connectionChange | setValue(&container.Status.ContainerID, containerID) | additionalReconciliationNeeded } } r.discardPhysicalContainerData(container.NamespacedName(), container.UID, data, log) - return deleteFinalizer(container, physicalContainerFinalizer, log) + return connectionChange | deleteFinalizer(container, physicalContainerFinalizer, log) } func (r *PhysicalContainerReconciler) processContainerEvent(em containers.EventMessage) { @@ -1115,19 +1367,6 @@ func physicalVolumeMountsToCreateContainerVolumeMounts(mounts []apiv2.VolumeMoun return retval } -func physicalNetworksToCreateContainerNetworks(networks []apiv2.ContainerNetworkConnectionConfig) []containers.CreateContainerNetworkOptions { - retval := make([]containers.CreateContainerNetworkOptions, len(networks)) - for i, network := range networks { - aliases := make([]string, len(network.Aliases)) - copy(aliases, network.Aliases) - retval[i] = containers.CreateContainerNetworkOptions{ - Name: network.Name, - Aliases: aliases, - } - } - return retval -} - func physicalContainerCreationLabels(container *apiv2.PhysicalContainer, log logr.Logger) []containers.Label { return physicalResourceCreationLabels( container.Spec.Container.Labels, diff --git a/controllers/physical_container_controller_test.go b/controllers/physical_container_controller_test.go deleted file mode 100644 index dc7986ca..00000000 --- a/controllers/physical_container_controller_test.go +++ /dev/null @@ -1,112 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package controllers - -import ( - "testing" - "time" - - "github.com/go-logr/logr" - apimeta "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - - "github.com/stretchr/testify/require" - - apiv2 "github.com/microsoft/dcp/api/v2" - "github.com/microsoft/dcp/internal/containers" - "github.com/microsoft/dcp/pkg/testutil" -) - -func TestPhysicalContainerCreateResultDoesNotReplaceExistingOwner(t *testing.T) { - t.Parallel() - ctx, cancel := testutil.GetTestContext(t, 30*time.Second) - defer cancel() - - containerID := "shared-container" - existingOwner := types.NamespacedName{Namespace: "test", Name: "existing"} - container := &apiv2.PhysicalContainer{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "test", - Name: "contender", - UID: types.UID("contender"), - }, - } - reconciler := NewPhysicalContainerReconciler(ctx, nil, nil, logr.Discard(), nil) - reconciler.containerData.Store(existingOwner, physicalContainerDataContainerIDKey(containerID), &physicalContainerData{ - resourceUID: types.UID("existing"), - state: physicalContainerStateRuntime, - progress: physicalResourceProgressRunning, - containerID: containerID, - }) - createStateKey := physicalContainerDataKey(container) - reconciler.containerData.Store(container.NamespacedName(), createStateKey, &physicalContainerData{ - resourceUID: container.UID, - state: physicalContainerStateCreate, - progress: physicalResourceProgressInProgress, - }) - - reconciler.queuePhysicalContainerDataResult(container, createStateKey, &physicalContainerData{ - resourceUID: container.UID, - state: physicalContainerStateCreate, - progress: physicalResourceProgressCompleted, - containerID: containerID, - }) - reconciler.containerData.RunDeferredOps(container.NamespacedName(), container) - - owner, ownedData := reconciler.containerData.BorrowByStateKey(physicalContainerDataContainerIDKey(containerID)) - require.Equal(t, existingOwner, owner) - require.NotNil(t, ownedData) - require.Equal(t, types.UID("existing"), ownedData.resourceUID) - - currentStateKey, currentData := reconciler.containerData.BorrowByNamespacedName(container.NamespacedName()) - require.Equal(t, createStateKey, currentStateKey) - require.NotNil(t, currentData) - require.Equal(t, physicalContainerStateResolve, currentData.state) - require.Equal(t, physicalResourceProgressRetryPending, currentData.progress) - require.Equal(t, containerID, currentData.containerID) - require.Contains(t, currentData.failureMessage, existingOwner.Name) -} - -func TestPhysicalContainerPortMappingFailurePreservesRuntimePhase(t *testing.T) { - t.Parallel() - - container := &apiv2.PhysicalContainer{ - ObjectMeta: metav1.ObjectMeta{ - Generation: 1, - }, - } - data := &physicalContainerData{} - reconciler := &PhysicalContainerReconciler{} - inspectedContainer := &containers.InspectedContainer{ - Id: "container-id", - Status: containers.ContainerStatusRunning, - Ports: containers.InspectedContainerPortMapping{ - "invalid-port": nil, - }, - } - - reconciler.applyInspectedPhysicalContainerStatus(container, data, inspectedContainer, logr.Discard()) - change, delay, valid := data.applyTo(container) - - require.True(t, valid) - require.Equal(t, apiv2.PhysicalContainerPhaseRunning, container.Status.Phase) - readyCondition := apimeta.FindStatusCondition(container.Status.Conditions, string(apiv2.ConditionReady)) - require.NotNil(t, readyCondition) - require.Equal(t, metav1.ConditionFalse, readyCondition.Status) - require.Equal(t, string(apiv2.PhysicalContainerReasonPortMappingResolutionFailed), readyCondition.Reason) - require.Equal(t, LongDelay, delay) - require.NotEqual(t, noChange, change&additionalReconciliationNeeded) - - firstTransition := readyCondition.LastTransitionTime - secondChange, secondDelay, secondValid := data.applyTo(container) - require.True(t, secondValid) - require.Equal(t, LongDelay, secondDelay) - require.Equal(t, noChange, secondChange&statusChanged) - readyCondition = apimeta.FindStatusCondition(container.Status.Conditions, string(apiv2.ConditionReady)) - require.NotNil(t, readyCondition) - require.Equal(t, firstTransition, readyCondition.LastTransitionTime) -} diff --git a/controllers/physical_container_data.go b/controllers/physical_container_data.go index 1db0d787..137ca482 100644 --- a/controllers/physical_container_data.go +++ b/controllers/physical_container_data.go @@ -12,6 +12,7 @@ import ( "k8s.io/apimachinery/pkg/types" apiv2 "github.com/microsoft/dcp/api/v2" + "github.com/microsoft/dcp/internal/containers" ) type physicalContainerDataStateKey string @@ -22,6 +23,7 @@ const ( physicalContainerStateNamespace physicalContainerState = iota + 1 physicalContainerStateResolve physicalContainerStateImage + physicalContainerStateNetworks physicalContainerStateCreate physicalContainerStateReplace physicalContainerStateCopyFiles @@ -56,6 +58,9 @@ type physicalContainerData struct { // Image name resolved from the referenced PhysicalContainerImage. image string + // Runtime networks resolved from the referenced PhysicalContainerNetworks. + networks []containers.CreateContainerNetworkOptions + // Diagnostic message from the current failed runtime operation. failureMessage string @@ -69,6 +74,44 @@ type physicalContainerData struct { retryAfter time.Time } +func clonePhysicalContainerNetworks(networks []containers.CreateContainerNetworkOptions) []containers.CreateContainerNetworkOptions { + if networks == nil { + return nil + } + + clonedNetworks := make([]containers.CreateContainerNetworkOptions, len(networks)) + for i := range networks { + clonedNetworks[i] = networks[i] + clonedNetworks[i].Aliases = append([]string{}, networks[i].Aliases...) + } + return clonedNetworks +} + +func physicalContainerNetworksEqual(left, right []containers.CreateContainerNetworkOptions) bool { + if len(left) != len(right) { + return false + } + + for i := range left { + if left[i].Name != right[i].Name || !stringSlicesEqual(left[i].Aliases, right[i].Aliases) { + return false + } + } + return true +} + +func stringSlicesEqual(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + func (data *physicalContainerData) Clone() *physicalContainerData { return &physicalContainerData{ resourceUID: data.resourceUID, @@ -76,6 +119,7 @@ func (data *physicalContainerData) Clone() *physicalContainerData { progress: data.progress, containerID: data.containerID, image: data.image, + networks: clonePhysicalContainerNetworks(data.networks), failureMessage: data.failureMessage, portMappingFailureMessage: data.portMappingFailureMessage, cleanupMessage: data.cleanupMessage, @@ -105,6 +149,10 @@ func (data *physicalContainerData) UpdateFrom(other *physicalContainerData) bool data.image = other.image updated = true } + if !physicalContainerNetworksEqual(data.networks, other.networks) { + data.networks = clonePhysicalContainerNetworks(other.networks) + updated = true + } if data.failureMessage != other.failureMessage { data.failureMessage = other.failureMessage updated = true @@ -211,6 +259,16 @@ var physicalContainerProjections = physicalResourceProjectionTable[physicalConta phase: apiv2.PhysicalContainerPhaseUnknown, conditionStatus: metav1.ConditionFalse, conditionReason: apiv2.PhysicalContainerReasonImageLookupFailed, requeue: true, requeueDelay: LongDelay, }, + {state: physicalContainerStateNetworks, progress: physicalResourceProgressNotFound}: { + phase: apiv2.PhysicalContainerPhasePending, conditionStatus: metav1.ConditionFalse, conditionReason: apiv2.PhysicalContainerReasonNetworkNotFound, + }, + {state: physicalContainerStateNetworks, progress: physicalResourceProgressNotReady}: { + phase: apiv2.PhysicalContainerPhasePending, conditionStatus: metav1.ConditionFalse, conditionReason: apiv2.PhysicalContainerReasonNetworkNotReady, + }, + {state: physicalContainerStateNetworks, progress: physicalResourceProgressRetryPending}: { + phase: apiv2.PhysicalContainerPhaseUnknown, conditionStatus: metav1.ConditionFalse, conditionReason: apiv2.PhysicalContainerReasonNetworkLookupFailed, + requeue: true, requeueDelay: LongDelay, + }, {state: physicalContainerStateCreate, progress: physicalResourceProgressInProgress}: { phase: apiv2.PhysicalContainerPhasePending, conditionStatus: metav1.ConditionFalse, conditionReason: apiv2.PhysicalContainerReasonCreating, message: "Physical container creation is in progress.", diff --git a/controllers/physical_container_image_controller.go b/controllers/physical_container_image_controller.go index 66944cc5..3abdc578 100644 --- a/controllers/physical_container_image_controller.go +++ b/controllers/physical_container_image_controller.go @@ -7,6 +7,8 @@ package controllers import ( "context" + "crypto/sha256" + "encoding/json" "errors" "fmt" stdio "io" @@ -67,8 +69,41 @@ const ( // Number of pull retries used when the image does not specify PullRetryLimit. defaultImagePullRetryLimit int32 = 3 + + physicalContainerImageBuildInputsLabel = "com.microsoft.developer.usvc-dev.buildInputs" ) +type physicalContainerImageBaseImageIdentity struct { + Image string `json:"image"` + Identity string `json:"identity"` +} + +type physicalContainerImageEnvironmentInputIdentity struct { + Name string `json:"name"` + Present bool `json:"present"` + Digest string `json:"digest,omitempty"` +} + +type physicalContainerImageFileInputIdentity struct { + Path string `json:"path"` + Digest string `json:"digest"` +} + +type physicalContainerImageBuildInputs struct { + ContextDigest string `json:"contextDigest,omitempty"` + Dockerfile string `json:"dockerfile,omitempty"` + Tags []string `json:"tags,omitempty"` + Args []commonapi.EnvVar `json:"args,omitempty"` + InheritedArgs []physicalContainerImageEnvironmentInputIdentity `json:"inheritedArgs,omitempty"` + Secrets []apiv2.ContainerBuildSecret `json:"secrets,omitempty"` + EnvironmentSecrets []physicalContainerImageEnvironmentInputIdentity `json:"environmentSecrets,omitempty"` + FileSecrets []physicalContainerImageFileInputIdentity `json:"fileSecrets,omitempty"` + Stage string `json:"stage,omitempty"` + Labels []commonapi.Label `json:"labels,omitempty"` + Platform string `json:"platform,omitempty"` + BaseImages []physicalContainerImageBaseImageIdentity `json:"baseImages,omitempty"` +} + // Builds the retry policy for pulling the given image. A PullRetryLimit of zero disables // retries entirely, so the pull fails as soon as the first attempt does. func imagePullBackoff(image *apiv2.PhysicalContainerImage) backoff.BackOff { @@ -257,7 +292,7 @@ func handlePhysicalContainerImageNamespace( } data.state = physicalContainerImageStateResolve - data.progress = physicalResourceProgressInProgress + data.progress = physicalResourceProgressNotReady data.failureMessage = "" return handlePhysicalContainerImageResolve(ctx, reconciler, image, data.state, data, log) } @@ -273,6 +308,16 @@ func handlePhysicalContainerImageResolve( if image.DeletionTimestamp != nil && !image.DeletionTimestamp.IsZero() { return beginPhysicalContainerImageDeletion(ctx, reconciler, image, data, log) } + + runtimeStatus := reconciler.orchestrator.CheckStatus(ctx, containers.CachedRuntimeStatusAllowed) + if !runtimeStatus.IsHealthy() { + log.V(1).Info("Container runtime is not healthy, retrying PhysicalContainerImage reconciliation later") + setPhysicalContainerImageRuntimeUnhealthy(data) + return noChange + } + + data.progress = physicalResourceProgressInProgress + data.failureMessage = "" if image.Spec.ImageID != "" { change, _ := reconciler.ensureExistingImage(ctx, image, data, log) return change @@ -306,8 +351,9 @@ func handlePhysicalContainerImageOperation( if time.Now().Before(data.retryAfter) { return additionalReconciliationNeeded } - change, _ := reconciler.schedulePhysicalContainerImagePull(image, data, image.Spec.Image.Image, log) - return change + data.state = physicalContainerImageStateResolve + data.progress = physicalResourceProgressNotReady + return handlePhysicalContainerImageResolve(ctx, reconciler, image, data.state, data, log) } if data.progress != physicalResourceProgressCompleted || data.imageID == "" { return handleUnknownPhysicalContainerImageState(ctx, reconciler, image, state, data, log) @@ -335,7 +381,7 @@ func handlePhysicalContainerImageRuntime( } data.state = physicalContainerImageStateResolve - data.progress = physicalResourceProgressInProgress + data.progress = physicalResourceProgressNotReady return handlePhysicalContainerImageResolve(ctx, reconciler, image, data.state, data, log) } @@ -439,7 +485,8 @@ func (r *PhysicalContainerImageReconciler) ensurePulledImage( log logr.Logger, ) (objectChange, AdditionalReconciliationDelay) { imageConfig := image.Spec.Image - if imageConfig.PullPolicy == apiv2.PullPolicyAlways { + if imageConfig.PullPolicy == apiv2.PullPolicyAlways || + imageConfig.PullPolicy == apiv2.PullPolicyBestEffort { return r.schedulePhysicalContainerImagePull(image, data, imageConfig.Image, log) } @@ -455,7 +502,12 @@ func (r *PhysicalContainerImageReconciler) ensurePulledImage( data.failureMessage = "" return noChange, StandardDelay } - if !errors.Is(inspectErr, containers.ErrNotFound) { + if inspectErr != nil && !errors.Is(inspectErr, containers.ErrNotFound) { + if runtimeErr := r.containerRuntimeUnhealthyError(ctx, inspectErr); runtimeErr != nil { + log.V(1).Info("Container runtime became unhealthy while inspecting PhysicalContainerImage source image, retrying reconciliation later", "Image", imageConfig.Image, "Error", runtimeErr) + setPhysicalContainerImageRuntimeUnhealthy(data) + return noChange, LongDelay + } log.Error(inspectErr, "Failed to inspect PhysicalContainerImage source image", "Image", imageConfig.Image) data.state = physicalContainerImageStateRuntime data.progress = physicalResourceProgressRetryPending @@ -473,17 +525,19 @@ func (r *PhysicalContainerImageReconciler) ensurePulledImage( } func (r *PhysicalContainerImageReconciler) ensureBuiltImage( - ctx context.Context, + _ context.Context, image *apiv2.PhysicalContainerImage, data *physicalContainerImageData, log logr.Logger, ) (objectChange, AdditionalReconciliationDelay) { imageConfig := image.Spec.Image outputImage := physicalContainerImageOutputTag(image) + buildContext := *imageConfig.Build buildContext.Tags = append([]string{}, buildContext.Tags...) buildContext.Args = append([]commonapi.EnvVar{}, buildContext.Args...) buildContext.Secrets = append([]apiv2.ContainerBuildSecret{}, buildContext.Secrets...) + buildContext.BaseImages = append([]string{}, buildContext.BaseImages...) buildContext.Labels = physicalResourceCreationLabels(buildContext.Labels, true, image.UID, log) buildContext.Tags = physicalContainerImageBuildTags(buildContext.Tags, outputImage) @@ -515,6 +569,11 @@ func (r *PhysicalContainerImageReconciler) ensureExistingImage( return noChange, StandardDelay } + if runtimeErr := r.containerRuntimeUnhealthyError(ctx, inspectErr); runtimeErr != nil { + log.V(1).Info("Container runtime became unhealthy while inspecting existing PhysicalContainerImage, retrying reconciliation later", "ImageID", image.Spec.ImageID, "Error", runtimeErr) + setPhysicalContainerImageRuntimeUnhealthy(data) + return noChange, LongDelay + } log.Error(inspectErr, "Failed to inspect existing PhysicalContainerImage", "ImageID", image.Spec.ImageID) data.state = physicalContainerImageStateRuntime data.progress = physicalResourceProgressRetryPending @@ -522,6 +581,123 @@ func (r *PhysicalContainerImageReconciler) ensureExistingImage( return noChange, LongDelay } +func physicalContainerImageBuildInputsFingerprint( + build *apiv2.ContainerBuildContext, + baseImages []physicalContainerImageBaseImageIdentity, +) (string, error) { + buildInputs := physicalContainerImageBuildInputs{ + ContextDigest: build.Digest, + Dockerfile: build.Dockerfile, + Tags: slices.Clone(build.Tags), + Args: slices.Clone(build.Args), + Secrets: slices.Clone(build.Secrets), + Stage: build.Stage, + Labels: slices.Clone(build.Labels), + Platform: build.Platform, + BaseImages: slices.Clone(baseImages), + } + + slices.SortFunc(buildInputs.Secrets, func(left, right apiv2.ContainerBuildSecret) int { + return strings.Compare(left.ID, right.ID) + }) + for _, buildArgument := range buildInputs.Args { + if buildArgument.Value == "" { + buildInputs.InheritedArgs = append( + buildInputs.InheritedArgs, + physicalContainerImageEnvironmentInput(buildArgument.Name), + ) + } + } + for _, secret := range buildInputs.Secrets { + switch secret.Type { + case "", apiv2.FileSecret: + fileIdentity, fileIdentityErr := physicalContainerImageFileInput(secret.Source) + if fileIdentityErr != nil { + return "", fmt.Errorf("identify build secret %q: %w", secret.ID, fileIdentityErr) + } + buildInputs.FileSecrets = append(buildInputs.FileSecrets, fileIdentity) + case apiv2.EnvSecret: + if secret.Value == "" { + secretSource := secret.Source + if secretSource == "" { + secretSource = secret.ID + } + buildInputs.EnvironmentSecrets = append( + buildInputs.EnvironmentSecrets, + physicalContainerImageEnvironmentInput(secretSource), + ) + } + } + } + slices.Sort(buildInputs.Tags) + buildInputs.Tags = slices.Compact(buildInputs.Tags) + buildInputs.Labels = slices.DeleteFunc(buildInputs.Labels, func(label commonapi.Label) bool { + switch label.Key { + case physicalContainerImageBuildInputsLabel, + PersistentLabel, + uidLabel, + CreatorProcessIdLabel, + CreatorProcessStartTimeLabel: + return true + default: + return false + } + }) + slices.SortFunc(buildInputs.Labels, func(left, right commonapi.Label) int { + return strings.Compare(left.Key, right.Key) + }) + slices.SortFunc(buildInputs.BaseImages, func(left, right physicalContainerImageBaseImageIdentity) int { + return strings.Compare(left.Image, right.Image) + }) + + encodedInputs, encodeErr := json.Marshal(buildInputs) + if encodeErr != nil { + return "", fmt.Errorf("encode build inputs: %w", encodeErr) + } + + return fmt.Sprintf("sha256:%x", sha256.Sum256(encodedInputs)), nil +} + +func physicalContainerImageEnvironmentInput(name string) physicalContainerImageEnvironmentInputIdentity { + value, present := os.LookupEnv(name) + identity := physicalContainerImageEnvironmentInputIdentity{ + Name: name, + Present: present, + } + if present { + identity.Digest = fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(value))) + } + return identity +} + +func physicalContainerImageFileInput(path string) (physicalContainerImageFileInputIdentity, error) { + file, openErr := usvc_io.OpenFileReadOnly(path) + if openErr != nil { + return physicalContainerImageFileInputIdentity{}, fmt.Errorf("open file %q: %w", path, openErr) + } + + hasher := sha256.New() + _, hashErr := stdio.Copy(hasher, file) + closeErr := file.Close() + var hashFileErr error + if hashErr != nil { + hashFileErr = fmt.Errorf("hash file %q: %w", path, hashErr) + } + var closeFileErr error + if closeErr != nil { + closeFileErr = fmt.Errorf("close file %q: %w", path, closeErr) + } + combinedFileErr := errors.Join(hashFileErr, closeFileErr) + if combinedFileErr != nil { + return physicalContainerImageFileInputIdentity{}, combinedFileErr + } + + return physicalContainerImageFileInputIdentity{ + Path: path, + Digest: fmt.Sprintf("sha256:%x", hasher.Sum(nil)), + }, nil +} + func (r *PhysicalContainerImageReconciler) schedulePhysicalContainerImagePull( image *apiv2.PhysicalContainerImage, currentData *physicalContainerImageData, @@ -600,7 +776,7 @@ func (r *PhysicalContainerImageReconciler) schedulePhysicalContainerImageBuild( return noChange, StandardDelay } - log.V(1).Info("Queued PhysicalContainerImage build", "Context", buildContext.Context, "Dockerfile", buildContext.Dockerfile, "Image", outputImage) + log.V(1).Info("Queued PhysicalContainerImage build", "Context", buildContext.Context, "ContextDigest", buildContext.Digest, "Dockerfile", buildContext.Dockerfile, "Image", outputImage) return noChange, StandardDelay } @@ -613,24 +789,38 @@ func (r *PhysicalContainerImageReconciler) pullPhysicalContainerImage( log logr.Logger, ) { log.V(1).Info("Pulling PhysicalContainerImage source image", "Image", outputImage) - attempt := 0 - pulledImageID, pullErr := resiliency.RetryGet(ctx, imagePullBackoff(image), func() (string, error) { - attempt++ - imageID, attemptErr := r.orchestrator.PullImage(ctx, containers.PullImageOptions{Image: outputImage}) - if attemptErr != nil { - log.V(1).Info("PhysicalContainerImage pull attempt failed", "Image", outputImage, "Attempt", attempt, "Error", attemptErr) + pulledImageID, pullErr := r.pullPhysicalContainerImageWithRetry(ctx, image, outputImage, log) + if errors.Is(pullErr, containers.ErrRuntimeNotHealthy) { + log.V(1).Info("Container runtime became unhealthy while pulling PhysicalContainerImage, retrying reconciliation later", "Image", outputImage, "Error", pullErr) + setPhysicalContainerImageRuntimeUnhealthy(data) + r.queuePhysicalContainerImageDataResult(image, stateKey, data) + return + } + if pullErr != nil || pulledImageID == "" { + if image.Spec.Image.PullPolicy == apiv2.PullPolicyBestEffort { + inspectedImage, inspectErr := inspectPhysicalContainerImage(ctx, r.orchestrator, outputImage) + if inspectErr == nil { + log.V(1).Info("Using local PhysicalContainerImage source image because the pull did not resolve an image", "Image", outputImage, "PullError", pullErr) + data.progress = physicalResourceProgressCompleted + data.imageID = inspectedImage.Id + data.failureMessage = "" + data.retryAfter = time.Time{} + r.queuePhysicalContainerImageDataResult(image, stateKey, data) + return + } + pullErr = errors.Join(pullErr, fmt.Errorf("inspect local image: %w", inspectErr)) + } + if pullErr == nil { + data.state = physicalContainerImageStatePull + data.progress = physicalResourceProgressResultMissing + data.failureMessage = "Image pull completed without an image ID." + data.retryAfter = time.Now().Add(delayDurations[LongDelay].Duration) + r.queuePhysicalContainerImageDataResult(image, stateKey, data) + return } - return imageID, attemptErr - }) - if pullErr != nil { log.Error(pullErr, "Failed to pull PhysicalContainerImage source image", "Image", outputImage) data.progress = physicalResourceProgressFailed data.failureMessage = fmt.Sprintf("Failed to pull image: %v", pullErr) - } else if pulledImageID == "" { - data.state = physicalContainerImageStatePull - data.progress = physicalResourceProgressResultMissing - data.failureMessage = "Image pull completed without an image ID." - data.retryAfter = time.Now().Add(delayDurations[LongDelay].Duration) } else { data.progress = physicalResourceProgressCompleted data.imageID = pulledImageID @@ -653,6 +843,49 @@ func (r *PhysicalContainerImageReconciler) buildPhysicalContainerImage( log.V(1).Info("Building PhysicalContainerImage", "Context", buildContext.Context, "Dockerfile", buildContext.Dockerfile, "Image", outputImage) defer r.queuePhysicalContainerImageDataResult(image, stateKey, data) + baseImages, resolveErr := r.resolvePhysicalContainerImageBuildBaseImages(ctx, image, buildContext.BaseImages, log) + if resolveErr != nil { + if errors.Is(resolveErr, containers.ErrRuntimeNotHealthy) { + log.V(1).Info("Container runtime became unhealthy while resolving PhysicalContainerImage build base images, retrying reconciliation later", "Image", outputImage, "Error", resolveErr) + setPhysicalContainerImageRuntimeUnhealthy(data) + return + } + log.Error(resolveErr, "Failed to resolve PhysicalContainerImage build base images", "Image", outputImage) + data.progress = physicalResourceProgressFailed + data.failureMessage = fmt.Sprintf("Failed to resolve image build base images: %v", resolveErr) + return + } + + buildInputs, buildInputsErr := physicalContainerImageBuildInputsFingerprint(buildContext, baseImages) + if buildInputsErr != nil { + data.progress = physicalResourceProgressFailed + data.failureMessage = fmt.Sprintf("Failed to identify image build inputs: %v", buildInputsErr) + return + } + + if buildContext.Digest != "" && + (image.Spec.Image.BuildPolicy == "" || image.Spec.Image.BuildPolicy == apiv2.BuildPolicyIfNeeded) { + inspectedImage, inspectErr := inspectPhysicalContainerImage(ctx, r.orchestrator, outputImage) + if inspectErr == nil && inspectedImage.Labels[physicalContainerImageBuildInputsLabel] == buildInputs { + data.progress = physicalResourceProgressCompleted + data.imageID = inspectedImage.Id + data.failureMessage = "" + return + } + if inspectErr != nil && !errors.Is(inspectErr, containers.ErrNotFound) { + if runtimeErr := r.containerRuntimeUnhealthyError(ctx, inspectErr); runtimeErr != nil { + log.V(1).Info("Container runtime became unhealthy while inspecting PhysicalContainerImage build output, retrying reconciliation later", "Image", outputImage, "Error", runtimeErr) + setPhysicalContainerImageRuntimeUnhealthy(data) + return + } + log.Error(inspectErr, "Failed to inspect PhysicalContainerImage build output", "Image", outputImage) + data.progress = physicalResourceProgressFailed + data.failureMessage = fmt.Sprintf("Failed to inspect image build output: %v", inspectErr) + return + } + } + buildContext.Labels = setRuntimeLabel(buildContext.Labels, physicalContainerImageBuildInputsLabel, buildInputs) + iidFile, openErr := usvc_io.EnsureEmptyTempFile(physicalContainerImageIDFileName(image), osutil.PermissionOnlyOwnerReadWrite) if openErr != nil { log.Error(openErr, "Failed to create PhysicalContainerImage build image ID file", "Image", outputImage) @@ -676,11 +909,15 @@ func (r *PhysicalContainerImageReconciler) buildPhysicalContainerImage( } buildErr := r.orchestrator.BuildImage(ctx, containers.BuildImageOptions{ - Pull: image.Spec.Image.PullPolicy == apiv2.PullPolicyAlways, IidFile: iidFileName, ContainerBuildContext: v2BuildContextToContainerBuildContext(buildContext), }) if buildErr != nil { + if runtimeErr := r.containerRuntimeUnhealthyError(ctx, buildErr); runtimeErr != nil { + log.V(1).Info("Container runtime became unhealthy while building PhysicalContainerImage, retrying reconciliation later", "Image", outputImage, "Error", runtimeErr) + setPhysicalContainerImageRuntimeUnhealthy(data) + return + } log.Error(buildErr, "Failed to build PhysicalContainerImage", "Image", outputImage) data.progress = physicalResourceProgressFailed data.failureMessage = fmt.Sprintf("Failed to build image: %v", buildErr) @@ -706,6 +943,143 @@ func (r *PhysicalContainerImageReconciler) buildPhysicalContainerImage( data.failureMessage = "" } +func (r *PhysicalContainerImageReconciler) resolvePhysicalContainerImageBuildBaseImages( + ctx context.Context, + image *apiv2.PhysicalContainerImage, + baseImages []string, + log logr.Logger, +) ([]physicalContainerImageBaseImageIdentity, error) { + sortedBaseImages := slices.Clone(baseImages) + slices.Sort(sortedBaseImages) + resolvedBaseImages := make([]physicalContainerImageBaseImageIdentity, 0, len(sortedBaseImages)) + for _, baseImage := range sortedBaseImages { + inspectedImage, resolveErr := r.resolvePhysicalContainerImageBuildBaseImage(ctx, image, baseImage, log) + if resolveErr != nil { + return nil, resolveErr + } + + baseImageIdentity := inspectedImage.Digest + if baseImageIdentity == "" { + baseImageIdentity = inspectedImage.Id + } + if baseImageIdentity == "" { + return nil, fmt.Errorf("base image %q has no immutable identity", baseImage) + } + resolvedBaseImages = append(resolvedBaseImages, physicalContainerImageBaseImageIdentity{ + Image: baseImage, + Identity: baseImageIdentity, + }) + } + return resolvedBaseImages, nil +} + +func (r *PhysicalContainerImageReconciler) resolvePhysicalContainerImageBuildBaseImage( + ctx context.Context, + image *apiv2.PhysicalContainerImage, + baseImage string, + log logr.Logger, +) (*containers.InspectedImage, error) { + pullPolicy := image.Spec.Image.PullPolicy + if pullPolicy == "" || pullPolicy == apiv2.PullPolicyMissing || pullPolicy == apiv2.PullPolicyNever { + inspectedImage, inspectErr := inspectPhysicalContainerImage(ctx, r.orchestrator, baseImage) + if inspectErr == nil { + return inspectedImage, nil + } + if !errors.Is(inspectErr, containers.ErrNotFound) { + if runtimeErr := r.containerRuntimeUnhealthyError(ctx, inspectErr); runtimeErr != nil { + return nil, runtimeErr + } + return nil, fmt.Errorf("inspect base image %q: %w", baseImage, inspectErr) + } + if pullPolicy == apiv2.PullPolicyNever { + return nil, fmt.Errorf("base image %q is not available locally: %w", baseImage, inspectErr) + } + } + + pulledImageID, pullErr := r.pullPhysicalContainerImageWithRetry(ctx, image, baseImage, log) + if pullErr == nil && pulledImageID != "" { + inspectedImage, inspectErr := inspectPhysicalContainerImage(ctx, r.orchestrator, pulledImageID) + if inspectErr != nil { + if runtimeErr := r.containerRuntimeUnhealthyError(ctx, inspectErr); runtimeErr != nil { + return nil, runtimeErr + } + return nil, fmt.Errorf("inspect pulled base image %q: %w", baseImage, inspectErr) + } + return inspectedImage, nil + } + + if pullPolicy != apiv2.PullPolicyBestEffort { + if pullErr != nil { + return nil, fmt.Errorf("pull base image %q: %w", baseImage, pullErr) + } + return nil, fmt.Errorf("pull base image %q: %w", baseImage, errPhysicalContainerImageIDMissing) + } + + inspectedImage, inspectErr := inspectPhysicalContainerImage(ctx, r.orchestrator, baseImage) + if inspectErr != nil { + if runtimeErr := r.containerRuntimeUnhealthyError(ctx, inspectErr); runtimeErr != nil { + return nil, runtimeErr + } + return nil, fmt.Errorf("pull base image %q and inspect local image: %w", baseImage, errors.Join(pullErr, inspectErr)) + } + log.V(1).Info("Using local PhysicalContainerImage build base image because the pull did not resolve an image", "BaseImage", baseImage, "PullError", pullErr) + return inspectedImage, nil +} + +func (r *PhysicalContainerImageReconciler) pullPhysicalContainerImageWithRetry( + ctx context.Context, + image *apiv2.PhysicalContainerImage, + imageReference string, + log logr.Logger, +) (string, error) { + attempt := 0 + return resiliency.RetryGet(ctx, imagePullBackoff(image), func() (string, error) { + runtimeStatus := r.orchestrator.CheckStatus(ctx, containers.CachedRuntimeStatusAllowed) + if !runtimeStatus.IsHealthy() { + return "", resiliency.Permanent(physicalContainerRuntimeUnhealthyError(runtimeStatus)) + } + + attempt++ + imageID, pullErr := r.orchestrator.PullImage(ctx, containers.PullImageOptions{Image: imageReference}) + if pullErr != nil { + runtimeErr := r.containerRuntimeUnhealthyError(ctx, pullErr) + if runtimeErr != nil { + return "", resiliency.Permanent(runtimeErr) + } + log.V(1).Info("PhysicalContainerImage pull attempt failed", "Image", imageReference, "Attempt", attempt, "Error", pullErr) + } + return imageID, pullErr + }) +} + +func (r *PhysicalContainerImageReconciler) containerRuntimeUnhealthyError( + ctx context.Context, + operationErr error, +) error { + runtimeStatus := r.orchestrator.CheckStatus(ctx, containers.IgnoreCachedRuntimeStatus) + if runtimeStatus.IsHealthy() { + return nil + } + + return errors.Join(operationErr, physicalContainerRuntimeUnhealthyError(runtimeStatus)) +} + +func physicalContainerRuntimeUnhealthyError(runtimeStatus containers.ContainerRuntimeStatus) error { + if runtimeStatus.Error != "" { + return fmt.Errorf("%w: %s", containers.ErrRuntimeNotHealthy, runtimeStatus.Error) + } + return containers.ErrRuntimeNotHealthy +} + +func setPhysicalContainerImageRuntimeUnhealthy( + data *physicalContainerImageData, +) { + data.state = physicalContainerImageStateResolve + data.progress = physicalResourceProgressNotReady + data.failureMessage = "Container runtime is not healthy." + data.retryAfter = time.Time{} +} + func (r *PhysicalContainerImageReconciler) queuePhysicalContainerImageDataResult( image *apiv2.PhysicalContainerImage, stateKey physicalContainerImageDataStateKey, @@ -810,7 +1184,18 @@ func v2BuildContextToContainerBuildContext(build *apiv2.ContainerBuildContext) * } return &containers.ContainerBuildContext{ - Context: build.Context, + Context: build.Context, + Digest: build.Digest, + ContextArchive: func() *containers.ContainerBuildContextArchive { + if build.ContextArchive == nil { + return nil + } + return &containers.ContainerBuildContextArchive{ + Source: build.ContextArchive.Source, + SHA256: build.ContextArchive.SHA256, + RawContents: build.ContextArchive.RawContents, + } + }(), Dockerfile: build.Dockerfile, Tags: build.Tags, Args: build.Args, diff --git a/controllers/physical_container_image_data.go b/controllers/physical_container_image_data.go index ea9f9ef8..75683fdb 100644 --- a/controllers/physical_container_image_data.go +++ b/controllers/physical_container_image_data.go @@ -169,6 +169,11 @@ var physicalContainerImageProjections = physicalResourceProjectionTable[physical conditionReason: apiv2.PhysicalResourceReasonNamespaceLookupFailed, requeue: true, requeueDelay: LongDelay, }, + {state: physicalContainerImageStateResolve, progress: physicalResourceProgressNotReady}: { + phase: apiv2.PhysicalContainerImagePhasePending, conditionStatus: metav1.ConditionFalse, + conditionReason: apiv2.PhysicalResourceReasonContainerRuntimeUnhealthy, + requeue: true, requeueDelay: LongDelay, + }, {state: physicalContainerImageStatePull, progress: physicalResourceProgressInProgress}: { phase: apiv2.PhysicalContainerImagePhasePending, conditionStatus: metav1.ConditionFalse, conditionReason: apiv2.PhysicalContainerImageReasonPulling, diff --git a/controllers/physical_container_network_controller.go b/controllers/physical_container_network_controller.go index 9d4c3167..65864fc3 100644 --- a/controllers/physical_container_network_controller.go +++ b/controllers/physical_container_network_controller.go @@ -22,12 +22,18 @@ import ( controller "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" apiv2 "github.com/microsoft/dcp/api/v2" "github.com/microsoft/dcp/internal/containers" "github.com/microsoft/dcp/pkg/resiliency" ) +const ( + physicalContainerNetworkConnectionContainerRefField = ".spec.containerRef" + physicalContainerNetworkConnectionNetworkRefField = ".spec.networkRef" +) + var ( physicalContainerNetworkFinalizer string = fmt.Sprintf("%s/physicalcontainernetwork-reconciler", apiv2.GroupVersion.Group) @@ -74,15 +80,103 @@ func NewPhysicalContainerNetworkReconciler( } func (r *PhysicalContainerNetworkReconciler) SetupWithManager(mgr ctrl.Manager, name string) error { + indexer := mgr.GetFieldIndexer() + containerRefIndexErr := indexer.IndexField( + context.Background(), + &apiv2.PhysicalContainerNetworkConnection{}, + physicalContainerNetworkConnectionContainerRefField, + func(rawObj ctrl_client.Object) []string { + connection := rawObj.(*apiv2.PhysicalContainerNetworkConnection) + if connection.Spec.ContainerRef == "" { + return nil + } + return []string{connection.Spec.ContainerRef} + }, + ) + if containerRefIndexErr != nil { + return fmt.Errorf("index PhysicalContainerNetworkConnections by container reference: %w", containerRefIndexErr) + } + + networkRefIndexErr := indexer.IndexField( + context.Background(), + &apiv2.PhysicalContainerNetworkConnection{}, + physicalContainerNetworkConnectionNetworkRefField, + func(rawObj ctrl_client.Object) []string { + connection := rawObj.(*apiv2.PhysicalContainerNetworkConnection) + if connection.Spec.NetworkRef == "" { + return nil + } + return []string{connection.Spec.NetworkRef} + }, + ) + if networkRefIndexErr != nil { + return fmt.Errorf("index PhysicalContainerNetworkConnections by network reference: %w", networkRefIndexErr) + } + return ctrl.NewControllerManagedBy(mgr). WithOptions(controller.Options{MaxConcurrentReconciles: MaxConcurrentReconciles}). For(&apiv2.PhysicalContainerNetwork{}). Watches(&apiv2.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.mapNamespaceToReconcileRequests(&apiv2.PhysicalContainerNetworkList{})), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Watches(&apiv2.PhysicalContainerNetworkConnection{}, handler.EnqueueRequestsFromMapFunc(r.networkForPhysicalContainerNetworkConnection), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). + Watches(&apiv2.PhysicalContainer{}, handler.EnqueueRequestsFromMapFunc(r.networksForPhysicalContainer), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). WatchesRawSource(r.GetReconciliationEventSource()). Named(name). Complete(r) } +func (r *PhysicalContainerNetworkReconciler) networkForPhysicalContainerNetworkConnection( + _ context.Context, + obj ctrl_client.Object, +) []reconcile.Request { + connection := obj.(*apiv2.PhysicalContainerNetworkConnection) + if connection.Spec.NetworkRef == "" { + return nil + } + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Namespace: connection.Namespace, + Name: connection.Spec.NetworkRef, + }, + }} +} + +func (r *PhysicalContainerNetworkReconciler) networksForPhysicalContainer( + ctx context.Context, + obj ctrl_client.Object, +) []reconcile.Request { + connections := apiv2.PhysicalContainerNetworkConnectionList{} + listErr := r.List( + ctx, + &connections, + ctrl_client.InNamespace(obj.GetNamespace()), + ctrl_client.MatchingFields{physicalContainerNetworkConnectionContainerRefField: obj.GetName()}, + ) + if listErr != nil { + r.Log.Error(listErr, "Failed to list PhysicalContainerNetworkConnections for PhysicalContainer", "PhysicalContainer", obj.GetName()) + return nil + } + + networkNames := make(map[string]struct{}, len(connections.Items)) + requests := make([]reconcile.Request, 0, len(connections.Items)) + for i := range connections.Items { + networkName := connections.Items[i].Spec.NetworkRef + if networkName == "" { + continue + } + if _, found := networkNames[networkName]; found { + continue + } + networkNames[networkName] = struct{}{} + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: networkName, + }, + }) + } + return requests +} + func (r *PhysicalContainerNetworkReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { reader, log := r.StartReconciliation(req) @@ -266,7 +360,9 @@ func (r *PhysicalContainerNetworkReconciler) applyRuntimeNetworkStatus( data.progress = physicalResourceProgressCompleted data.networkID = inspectedNetwork.Id data.failureMessage = "" - return applyReadyPhysicalContainerNetworkStatus(network, inspectedNetwork) + change := applyReadyPhysicalContainerNetworkStatus(network, inspectedNetwork) + change |= r.ensurePhysicalContainerNetworkConnections(ctx, network, data, inspectedNetwork, log) + return change } func (r *PhysicalContainerNetworkReconciler) schedulePhysicalContainerNetworkCreate( @@ -1026,6 +1122,181 @@ func inspectPhysicalContainerNetwork(ctx context.Context, orchestrator container return nil, containers.ErrNotFound } +type desiredPhysicalContainerNetworkConnection struct { + containerID string + aliases []string +} + +func (r *PhysicalContainerNetworkReconciler) ensurePhysicalContainerNetworkConnections( + ctx context.Context, + network *apiv2.PhysicalContainerNetwork, + data *physicalContainerNetworkData, + inspectedNetwork *containers.InspectedNetwork, + log logr.Logger, +) objectChange { + connections := apiv2.PhysicalContainerNetworkConnectionList{} + listErr := r.List( + ctx, + &connections, + ctrl_client.InNamespace(network.Namespace), + ctrl_client.MatchingFields{physicalContainerNetworkConnectionNetworkRefField: network.Name}, + ) + if listErr != nil { + log.Error(listErr, "Failed to list PhysicalContainerNetworkConnections") + return additionalReconciliationNeeded + } + + if data.connections == nil { + data.connections = make(map[string]int) + } + + runtimeStatus := r.orchestrator.CheckStatus(ctx, containers.CachedRuntimeStatusAllowed) + if !runtimeStatus.IsHealthy() { + return additionalReconciliationNeeded + } + + desiredConnections := make(map[string]*desiredPhysicalContainerNetworkConnection, len(connections.Items)) + for i := range connections.Items { + connection := &connections.Items[i] + container := apiv2.PhysicalContainer{} + containerName := types.NamespacedName{Namespace: connection.Namespace, Name: connection.Spec.ContainerRef} + containerLookupErr := r.Get(ctx, containerName, &container) + switch { + case apierrors.IsNotFound(containerLookupErr): + continue + case containerLookupErr != nil: + log.Error(containerLookupErr, "Failed to get referenced PhysicalContainer", "PhysicalContainer", containerName) + return additionalReconciliationNeeded + case container.Status.ContainerID == "": + continue + } + + containerID := container.Status.ContainerID + desiredConnection := desiredConnections[containerID] + if desiredConnection == nil { + desiredConnection = &desiredPhysicalContainerNetworkConnection{ + containerID: containerID, + } + desiredConnections[containerID] = desiredConnection + } + desiredConnection.aliases = appendUniqueStrings(desiredConnection.aliases, connection.Spec.Aliases...) + } + + connectedContainers := physicalContainerNetworkContainerIDs(inspectedNetwork) + change := noChange + membershipOperationAttempted := false + for containerID := range data.connections { + if _, desired := desiredConnections[containerID]; desired { + continue + } + if !physicalContainerNetworkContainsContainer(connectedContainers, containerID) { + delete(data.connections, containerID) + continue + } + + membershipOperationAttempted = true + disconnectErr := r.orchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: inspectedNetwork.Id, + Container: containerID, + }) + if disconnectErr != nil && !errors.Is(disconnectErr, containers.ErrNotFound) { + data.connections[containerID]++ + if data.connections[containerID]%logAfterFailures == 0 { + log.Error(disconnectErr, "Could not disconnect a physical container from the network", "ContainerID", containerID) + } + change |= additionalReconciliationNeeded + continue + } + delete(data.connections, containerID) + } + + for containerID, desiredConnection := range desiredConnections { + if physicalContainerNetworkContainsContainer(connectedContainers, containerID) { + data.connections[containerID] = 0 + continue + } + + membershipOperationAttempted = true + connectErr := r.orchestrator.ConnectNetwork(ctx, containers.ConnectNetworkOptions{ + Network: inspectedNetwork.Id, + Container: containerID, + Aliases: desiredConnection.aliases, + }) + if connectErr != nil && + !errors.Is(connectErr, containers.ErrAlreadyExists) && + !errors.Is(connectErr, containers.ErrNotFound) { + data.connections[containerID]++ + if data.connections[containerID]%logAfterFailures == 0 { + log.Error(connectErr, "Could not connect a physical container to the network", "ContainerID", containerID) + } + change |= additionalReconciliationNeeded + continue + } + data.connections[containerID] = 0 + } + + verifiedNetwork := inspectedNetwork + if membershipOperationAttempted { + var verifyErr error + verifiedNetwork, verifyErr = inspectPhysicalContainerNetwork(ctx, r.orchestrator, inspectedNetwork.Id) + if verifyErr != nil { + log.Error(verifyErr, "Could not verify physical container network state") + return change | additionalReconciliationNeeded + } + } + + change |= setPhysicalContainerNetworkContainerIDs(&network.Status.ContainerIDs, verifiedNetwork.Containers) + verifiedContainerIDs := physicalContainerNetworkContainerIDs(verifiedNetwork) + connected := 0 + for containerID := range desiredConnections { + if physicalContainerNetworkContainsContainer(verifiedContainerIDs, containerID) { + connected++ + data.connections[containerID] = 0 + } + } + + countsChanged := data.expected != len(desiredConnections) || data.connected != connected + data.expected = len(desiredConnections) + data.connected = connected + if countsChanged { + if data.connected < data.expected { + log.V(1).Info("Not all expected physical containers are connected to the network, retrying...", "Expected", data.expected, "Found", data.connected) + change |= additionalReconciliationNeeded + } else { + log.Info("All expected physical containers are connected to the network", "Expected", data.expected, "Found", data.connected) + } + } + + return change +} + +func physicalContainerNetworkContainerIDs(network *containers.InspectedNetwork) map[string]struct{} { + containerIDs := make(map[string]struct{}, len(network.Containers)) + for _, container := range network.Containers { + containerIDs[container.Id] = struct{}{} + } + return containerIDs +} + +func appendUniqueStrings(destination []string, values ...string) []string { + for _, value := range values { + if slices.Contains(destination, value) { + continue + } + destination = append(destination, value) + } + return destination +} + +func physicalContainerNetworkContainsContainer(containerIDs map[string]struct{}, containerID string) bool { + if containerID == "" { + return false + } + + _, found := containerIDs[containerID] + return found +} + func physicalContainerNetworkCreationLabels(network *apiv2.PhysicalContainerNetwork, log logr.Logger) map[string]string { networkConfig := network.Spec.Network creationLabels := physicalResourceCreationLabels( @@ -1055,6 +1326,7 @@ func applyReadyPhysicalContainerNetworkStatus(network *apiv2.PhysicalContainerNe change |= setValue(&network.Status.IPv6, inspectedNetwork.IPv6) change |= setPhysicalContainerNetworkAddresses(&network.Status.Subnets, inspectedNetwork.Subnets) change |= setPhysicalContainerNetworkAddresses(&network.Status.Gateways, inspectedNetwork.Gateways) + change |= setPhysicalContainerNetworkContainerIDs(&network.Status.ContainerIDs, inspectedNetwork.Containers) change |= setTimestamp(&network.Status.CreatedAt, metav1.NewMicroTime(inspectedNetwork.CreatedAt)) change |= setValue(&network.Status.Phase, apiv2.PhysicalContainerNetworkPhaseReady) change |= setCondition(&network.Status.Conditions, apiv2.ConditionReady, network.Generation, metav1.ConditionTrue, apiv2.PhysicalContainerNetworkReasonNetworkAvailable, "Runtime network is available.") @@ -1062,6 +1334,20 @@ func applyReadyPhysicalContainerNetworkStatus(network *apiv2.PhysicalContainerNe return change | additionalReconciliationNeeded } +func setPhysicalContainerNetworkContainerIDs(target *[]string, networkContainers []containers.InspectedNetworkContainer) objectChange { + containerIDs := make([]string, len(networkContainers)) + for i := range networkContainers { + containerIDs[i] = networkContainers[i].Id + } + slices.Sort(containerIDs) + if slices.Equal(*target, containerIDs) { + return noChange + } + + *target = containerIDs + return statusChanged +} + func setPhysicalContainerNetworkAddresses(target *[]string, addresses []string) objectChange { if slices.Equal(*target, addresses) { return noChange diff --git a/controllers/physical_container_network_data.go b/controllers/physical_container_network_data.go index 4c890fc0..c1106119 100644 --- a/controllers/physical_container_network_data.go +++ b/controllers/physical_container_network_data.go @@ -6,6 +6,7 @@ package controllers import ( + stdmaps "maps" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -41,6 +42,9 @@ type physicalContainerNetworkData struct { failureMessage string retryAfter time.Time resolveByName bool + connections map[string]int + expected int + connected int } func (data *physicalContainerNetworkData) Clone() *physicalContainerNetworkData { @@ -51,6 +55,9 @@ func (data *physicalContainerNetworkData) Clone() *physicalContainerNetworkData failureMessage: data.failureMessage, retryAfter: data.retryAfter, resolveByName: data.resolveByName, + connections: stdmaps.Clone(data.connections), + expected: data.expected, + connected: data.connected, } } @@ -84,6 +91,18 @@ func (data *physicalContainerNetworkData) UpdateFrom(other *physicalContainerNet data.resolveByName = other.resolveByName updated = true } + if !stdmaps.Equal(data.connections, other.connections) { + data.connections = stdmaps.Clone(other.connections) + updated = true + } + if data.expected != other.expected { + data.expected = other.expected + updated = true + } + if data.connected != other.connected { + data.connected = other.connected + updated = true + } return updated } diff --git a/internal/containers/build_context_archive.go b/internal/containers/build_context_archive.go new file mode 100644 index 00000000..6f8994c5 --- /dev/null +++ b/internal/containers/build_context_archive.go @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "encoding/base64" + "fmt" + "io" + + usvc_io "github.com/microsoft/dcp/pkg/io" +) + +// OpenBuildContextArchive verifies and opens an archive for streaming to an image builder. +func OpenBuildContextArchive(archive *ContainerBuildContextArchive) (io.ReadCloser, error) { + if archive == nil { + return nil, fmt.Errorf("build context archive is required") + } + if archive.Source != "" && archive.RawContents != "" { + return nil, fmt.Errorf("build context archive source and raw contents are mutually exclusive") + } + if archive.Source == "" { + if archive.RawContents == "" { + return nil, fmt.Errorf("build context archive source or raw contents is required") + } + contents, decodeErr := base64.StdEncoding.DecodeString(archive.RawContents) + if decodeErr != nil { + return nil, fmt.Errorf("decode build context archive raw contents: %w", decodeErr) + } + return io.NopCloser(bytes.NewReader(contents)), nil + } + + archiveFile, openErr := usvc_io.OpenFileReadOnly(archive.Source) + if openErr != nil { + return nil, fmt.Errorf("open build context archive %q: %w", archive.Source, openErr) + } + + if verifyErr := verifySHA256(archiveFile, archive.SHA256); verifyErr != nil { + _ = archiveFile.Close() + return nil, fmt.Errorf("verifying build context archive %q: %w", archive.Source, verifyErr) + } + + if _, seekErr := archiveFile.Seek(0, io.SeekStart); seekErr != nil { + _ = archiveFile.Close() + return nil, fmt.Errorf("rewind build context archive %q: %w", archive.Source, seekErr) + } + + return archiveFile, nil +} diff --git a/internal/containers/build_context_archive_test.go b/internal/containers/build_context_archive_test.go new file mode 100644 index 00000000..a29dd608 --- /dev/null +++ b/internal/containers/build_context_archive_test.go @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "encoding/base64" + "fmt" + "io" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + usvc_io "github.com/microsoft/dcp/pkg/io" + "github.com/microsoft/dcp/pkg/osutil" +) + +func TestOpenBuildContextArchive(t *testing.T) { + t.Parallel() + + contents := []byte("build context") + archivePath := filepath.Join(t.TempDir(), "context.tar") + require.NoError(t, usvc_io.WriteFile(archivePath, contents, osutil.PermissionOnlyOwnerReadWrite)) + hash := sha256.Sum256(contents) + + archiveFile, openErr := OpenBuildContextArchive(&ContainerBuildContextArchive{ + Source: archivePath, + SHA256: fmt.Sprintf("sha256:%x", hash), + }) + require.NoError(t, openErr) + defer archiveFile.Close() + + actualContents, readErr := io.ReadAll(archiveFile) + require.NoError(t, readErr) + require.Equal(t, contents, actualContents) +} + +func TestOpenBuildContextArchiveRejectsHashMismatch(t *testing.T) { + t.Parallel() + + archivePath := filepath.Join(t.TempDir(), "context.tar") + require.NoError(t, usvc_io.WriteFile(archivePath, []byte("build context"), osutil.PermissionOnlyOwnerReadWrite)) + + _, openErr := OpenBuildContextArchive(&ContainerBuildContextArchive{ + Source: archivePath, + SHA256: "deadbeef", + }) + require.ErrorContains(t, openErr, "SHA256 mismatch") +} + +func TestOpenBuildContextArchiveRawContents(t *testing.T) { + t.Parallel() + + contents := []byte("build context") + archiveReader, openErr := OpenBuildContextArchive(&ContainerBuildContextArchive{ + RawContents: base64.StdEncoding.EncodeToString(contents), + }) + require.NoError(t, openErr) + defer archiveReader.Close() + + actualContents, readErr := io.ReadAll(archiveReader) + require.NoError(t, readErr) + require.Equal(t, contents, actualContents) +} + +func TestOpenBuildContextArchiveRejectsMissingContents(t *testing.T) { + t.Parallel() + + _, openErr := OpenBuildContextArchive(&ContainerBuildContextArchive{}) + require.ErrorContains(t, openErr, "source or raw contents is required") +} + +func TestOpenBuildContextArchiveRejectsConflictingContents(t *testing.T) { + t.Parallel() + + _, openErr := OpenBuildContextArchive(&ContainerBuildContextArchive{ + Source: "context.tar", + RawContents: "dGVzdA==", + }) + require.ErrorContains(t, openErr, "mutually exclusive") +} diff --git a/internal/containers/image_build.go b/internal/containers/image_build.go new file mode 100644 index 00000000..7ed9ec8d --- /dev/null +++ b/internal/containers/image_build.go @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "fmt" + "io" + "os" + "time" +) + +const defaultBuildImageTimeout = 10 * time.Minute + +// BuildImageImpl builds a container image using the container runtime CLI. +// Additional arguments are appended immediately before the build context argument. +func BuildImageImpl( + ctx context.Context, + options BuildImageOptions, + runner CLICommandRunner, + additionalArgs ...string, +) (*bytes.Buffer, error) { + if options.ContainerBuildContext == nil { + return nil, fmt.Errorf("container build context is required") + } + if options.Context == "" && options.ContextArchive == nil { + return nil, fmt.Errorf("build context path or build context archive is required") + } + if options.Context != "" && options.ContextArchive != nil { + return nil, fmt.Errorf("build context path and build context archive are mutually exclusive") + } + + args := []string{"build"} + + if options.Dockerfile != "" { + args = append(args, "-f", options.Dockerfile) + } + + if options.Pull { + args = append(args, "--pull") + } + + if options.IidFile != "" { + args = append(args, "--iidfile", options.IidFile) + } + + for _, tag := range options.Tags { + args = append(args, "-t", tag) + } + + for _, buildArg := range options.Args { + if buildArg.Value != "" { + args = append(args, "--build-arg", fmt.Sprintf("%s=%s", buildArg.Name, buildArg.Value)) + } else { + args = append(args, "--build-arg", buildArg.Name) + } + } + + secretEnvironment := map[string]string{} + for _, secret := range options.Secrets { + switch secret.Type { + case FileSecret, "": + args = append(args, "--secret", fmt.Sprintf("id=%s,src=%s", secret.ID, secret.Source)) + case EnvSecret: + secretSource := secret.Source + if secretSource == "" { + secretSource = secret.ID + } + args = append(args, "--secret", fmt.Sprintf("id=%s,env=%s", secret.ID, secretSource)) + if secret.Value != "" { + secretEnvironment[secretSource] = secret.Value + } + } + } + + if options.Stage != "" { + args = append(args, "--target", options.Stage) + } + + for _, label := range options.Labels { + args = append(args, "--label", fmt.Sprintf("%s=%s", label.Key, label.Value)) + } + + if options.Platform != "" { + args = append(args, "--platform", options.Platform) + } + + args = append(args, additionalArgs...) + + buildContextArgument := options.Context + var buildContextArchive io.ReadCloser + if options.ContextArchive != nil { + var archiveErr error + buildContextArchive, archiveErr = OpenBuildContextArchive(options.ContextArchive) + if archiveErr != nil { + return nil, archiveErr + } + defer buildContextArchive.Close() + buildContextArgument = "-" + } + args = append(args, buildContextArgument) + + cmd := runner.MakeCommand(args...) + if buildContextArchive != nil { + cmd.Stdin = buildContextArchive + } + + cmd.Env = os.Environ() + for secretName, secretValue := range secretEnvironment { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", secretName, secretValue)) + } + + timeout := options.Timeout + if timeout == 0 { + timeout = defaultBuildImageTimeout + } + + _, errBuf, buildErr := runner.RunBufferedCommand(ctx, "BuildImage", cmd, options.StdOutStream, options.StdErrStream, timeout) + return errBuf, buildErr +} diff --git a/internal/containers/image_build_test.go b/internal/containers/image_build_test.go new file mode 100644 index 00000000..4a1d5c50 --- /dev/null +++ b/internal/containers/image_build_test.go @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package containers + +import ( + "encoding/base64" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildImageImplBuildsCommand(t *testing.T) { + result := &fakeBuildResult{} + runner := newFakeRunner(result, "", "", nil) + timeout := 3 * time.Minute + + errBuf, buildErr := BuildImageImpl( + t.Context(), + BuildImageOptions{ + IidFile: "image-id", + Pull: true, + ContainerBuildContext: &ContainerBuildContext{ + Context: "context-dir", + Dockerfile: "Containerfile", + Tags: []string{"example:first", "example:second"}, + Args: []EnvVar{ + {Name: "SET", Value: "value"}, + {Name: "UNSET"}, + }, + Secrets: []ContainerBuildSecret{ + {Type: FileSecret, ID: "file-secret", Source: "secret.txt"}, + {Type: EnvSecret, ID: "env-secret", Source: "SOURCE_ENV", Value: "secret-value"}, + {Type: EnvSecret, ID: "default-env-secret", Value: "default-secret-value"}, + }, + Stage: "final", + Labels: []Label{{Key: "key", Value: "value"}}, + Platform: "linux/amd64", + }, + TimeoutOption: TimeoutOption{Timeout: timeout}, + }, + runner, + "--runtime-option", + ) + + require.NoError(t, buildErr) + require.NotNil(t, errBuf) + assert.Equal(t, "BuildImage", result.operationName) + assert.Equal(t, timeout, result.timeout) + assert.Equal(t, []string{ + "echo", + "build", + "-f", "Containerfile", + "--pull", + "--iidfile", "image-id", + "-t", "example:first", + "-t", "example:second", + "--build-arg", "SET=value", + "--build-arg", "UNSET", + "--secret", "id=file-secret,src=secret.txt", + "--secret", "id=env-secret,env=SOURCE_ENV", + "--secret", "id=default-env-secret,env=default-env-secret", + "--target", "final", + "--label", "key=value", + "--platform", "linux/amd64", + "--runtime-option", + "context-dir", + }, result.args) + assert.Contains(t, result.env, "SOURCE_ENV=secret-value") + assert.Contains(t, result.env, "default-env-secret=default-secret-value") + assert.Empty(t, result.stdinTar) +} + +func TestBuildImageImplStreamsArchive(t *testing.T) { + result := &fakeBuildResult{} + archiveContents := []byte("archive contents") + + _, buildErr := BuildImageImpl( + t.Context(), + BuildImageOptions{ + ContainerBuildContext: &ContainerBuildContext{ + ContextArchive: &ContainerBuildContextArchive{ + RawContents: base64.StdEncoding.EncodeToString(archiveContents), + }, + }, + }, + newFakeRunner(result, "", "", nil), + ) + + require.NoError(t, buildErr) + assert.Equal(t, []string{"echo", "build", "-"}, result.args) + assert.Equal(t, archiveContents, result.stdinTar) + assert.Equal(t, defaultBuildImageTimeout, result.timeout) +} + +func TestBuildImageImplRejectsPathAndArchive(t *testing.T) { + result := &fakeBuildResult{} + + _, buildErr := BuildImageImpl( + t.Context(), + BuildImageOptions{ + ContainerBuildContext: &ContainerBuildContext{ + Context: "context-dir", + ContextArchive: &ContainerBuildContextArchive{ + RawContents: base64.StdEncoding.EncodeToString([]byte("archive contents")), + }, + }, + }, + newFakeRunner(result, "", "", nil), + ) + + require.EqualError(t, buildErr, "build context path and build context archive are mutually exclusive") + assert.Empty(t, result.args) +} + +func TestBuildImageImplRejectsMissingContext(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + options BuildImageOptions + expectedError string + }{ + "missing container build context": { + options: BuildImageOptions{}, + expectedError: "container build context is required", + }, + "missing build context source": { + options: BuildImageOptions{ + ContainerBuildContext: &ContainerBuildContext{}, + }, + expectedError: "build context path or build context archive is required", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + result := &fakeBuildResult{} + + _, buildErr := BuildImageImpl( + t.Context(), + testCase.options, + newFakeRunner(result, "", "", nil), + ) + + require.EqualError(t, buildErr, testCase.expectedError) + assert.Empty(t, result.args) + }) + } +} + +func TestBuildImageImplReturnsStderrOnFailure(t *testing.T) { + result := &fakeBuildResult{} + expectedErr := errors.New("build failed") + + errBuf, buildErr := BuildImageImpl( + t.Context(), + BuildImageOptions{ContainerBuildContext: &ContainerBuildContext{Context: "context-dir"}}, + newFakeRunner(result, "", "runtime error", expectedErr), + ) + + require.ErrorIs(t, buildErr, expectedErr) + require.NotNil(t, errBuf) + assert.Equal(t, "runtime error", errBuf.String()) +} diff --git a/internal/containers/image_layers.go b/internal/containers/image_layers.go index a130c9e3..dfa2ff32 100644 --- a/internal/containers/image_layers.go +++ b/internal/containers/image_layers.go @@ -196,18 +196,28 @@ func verifyLayerSourceHash(layer *ImageLayer) error { } defer f.Close() + if verifyErr := verifySHA256(f, layer.SHA256); verifyErr != nil { + return fmt.Errorf("verifying layer source %q: %w", layer.Source, verifyErr) + } + + return nil +} + +// verifySHA256 hashes everything readable from r and compares it against expectedSHA256, +// which may be a bare hex digest or one carrying the "sha256:" prefix. +func verifySHA256(r io.Reader, expectedSHA256 string) error { hasher := sha256.New() - if _, copyErr := io.Copy(hasher, f); copyErr != nil { - return fmt.Errorf("hashing layer source file %q: %w", layer.Source, copyErr) + if _, copyErr := io.Copy(hasher, r); copyErr != nil { + return fmt.Errorf("hashing contents: %w", copyErr) } actualHashHex := hex.EncodeToString(hasher.Sum(nil)) - expectedHash := strings.TrimSpace(layer.SHA256) + expectedHash := strings.TrimSpace(expectedSHA256) if strings.HasPrefix(strings.ToLower(expectedHash), "sha256:") { - expectedHash = expectedHash[7:] + expectedHash = expectedHash[len("sha256:"):] } if !strings.EqualFold(actualHashHex, expectedHash) { - return fmt.Errorf("SHA256 mismatch for layer source %q: expected %s, got %s", layer.Source, layer.SHA256, actualHashHex) + return fmt.Errorf("SHA256 mismatch: expected %s, got %s", expectedSHA256, actualHashHex) } return nil diff --git a/internal/containers/image_layers_test.go b/internal/containers/image_layers_test.go index 119ffd43..dfaa27af 100644 --- a/internal/containers/image_layers_test.go +++ b/internal/containers/image_layers_test.go @@ -38,8 +38,11 @@ type fakeCLICommandRunner struct { // fakeBuildResult captures the stdin tar and args for inspection by tests. type fakeBuildResult struct { - args []string - stdinTar []byte + args []string + stdinTar []byte + env []string + operationName string + timeout time.Duration } func (f *fakeCLICommandRunner) MakeCommand(args ...string) *exec.Cmd { @@ -48,6 +51,9 @@ func (f *fakeCLICommandRunner) MakeCommand(args ...string) *exec.Cmd { func (f *fakeCLICommandRunner) RunBufferedCommand(ctx context.Context, opName string, cmd *exec.Cmd, stdout io.WriteCloser, stderr io.WriteCloser, timeout time.Duration) (*bytes.Buffer, *bytes.Buffer, error) { f.result.args = cmd.Args + f.result.env = cmd.Env + f.result.operationName = opName + f.result.timeout = timeout if cmd.Stdin != nil { data, readErr := io.ReadAll(cmd.Stdin) diff --git a/internal/containers/image_orchestrator.go b/internal/containers/image_orchestrator.go index 977a2738..b1d5ec5b 100644 --- a/internal/containers/image_orchestrator.go +++ b/internal/containers/image_orchestrator.go @@ -73,6 +73,12 @@ type ContainerBuildContext struct { // The path to the directory to be used as the root of the build context. Context string `json:"context"` + // A tar archive to stream to the image builder as the build context. + ContextArchive *ContainerBuildContextArchive `json:"contextArchive,omitempty"` + + // An opaque identifier for the logical contents of the build context. + Digest string `json:"digest,omitempty"` + // The path to a Dockerfile to use for the build. Dockerfile string `json:"dockerfile,omitempty"` @@ -95,6 +101,12 @@ type ContainerBuildContext struct { Platform string `json:"platform,omitempty"` } +type ContainerBuildContextArchive struct { + Source string `json:"source,omitempty"` + SHA256 string `json:"sha256,omitempty"` + RawContents string `json:"rawContents,omitempty"` +} + type BuildImageOptions struct { IidFile string Pull bool diff --git a/internal/dcpctrl/commands/run_controllers.go b/internal/dcpctrl/commands/run_controllers.go index 388f4c58..95eacce3 100644 --- a/internal/dcpctrl/commands/run_controllers.go +++ b/internal/dcpctrl/commands/run_controllers.go @@ -280,6 +280,7 @@ func runControllers(log logr.Logger) func(cmd *cobra.Command, _ []string) error mgr.GetAPIReader(), log.WithName("PhysicalContainerReconciler"), containerOrchestrator, + processExecutor, ) if err = physicalContainerCtrl.SetupWithManager(mgr, defaultControllerName); err != nil { log.Error(err, "Unable to set up PhysicalContainer controller") @@ -374,7 +375,6 @@ func runControllers(log logr.Logger) func(cmd *cobra.Command, _ []string) error mgr.GetClient(), mgr.GetAPIReader(), controllers.ContainerNetworkTunnelProxyReconcilerConfig{ - Orchestrator: containerOrchestrator, ProcessExecutor: processExecutor, MakeTunnelControlClient: dcptunproto.NewTunnelControlClient, }, diff --git a/internal/dcptun/image_builder.go b/internal/dcptun/image_builder.go index f24792d7..1d1f01ef 100644 --- a/internal/dcptun/image_builder.go +++ b/internal/dcptun/image_builder.go @@ -8,41 +8,25 @@ package dcptun import ( "context" "crypto/sha256" - "errors" + "encoding/json" "fmt" "io" "os" "path/filepath" "time" - "github.com/cenkalti/backoff/v4" - "github.com/go-logr/logr" - "github.com/microsoft/dcp/internal/containers" "github.com/microsoft/dcp/internal/dcppaths" - "github.com/microsoft/dcp/internal/networking" "github.com/microsoft/dcp/internal/version" - "github.com/microsoft/dcp/pkg/concurrency" usvc_io "github.com/microsoft/dcp/pkg/io" "github.com/microsoft/dcp/pkg/osutil" - "github.com/microsoft/dcp/pkg/resiliency" - "github.com/microsoft/dcp/pkg/slices" + "github.com/microsoft/dcp/pkg/randdata" ) const ( // Default base image for client proxy containers DefaultBaseImage = "mcr.microsoft.com/azurelinux/base/core:3.0" - // The interval at which we check whether the client proxy image has been built - // (assuming another instance is building it). - checkImageBuiltInterval = 5 * time.Second - - // How long are we willing to wait for the result on already-started image build. - defaultImageBuildTimeout = 1 * time.Minute - - // The label containing the base image digest used for the client proxy image build. - baseImageDigestLabel = "com.microsoft.developer.usvc-dev.base-image-digest" - dockerfileName = "Dockerfile" ) @@ -59,343 +43,159 @@ const ( ClientProxyContainerImageNamePrefix = "dcptun_developer_ms" ) -var ( - // Protects critical sections of code that handles proxy image builds. - imageBuildLock = concurrency.NewContextAwareLock() - - // Map (base image) --> (image digest) for client proxy image builds. - // Used as means of verifying that the base image used for the build is the latest we can find, - // and that the client proxy image is not stale. - baseImageDigests = make(map[string]imageDigest) -) - -type ErrContainerRuntimeUnhealthy struct { - Reason string +type ClientProxyImageBuildPlan struct { + Image string + BuildContextDigest string + BuildContextArchive *containers.ContainerBuildContextArchive + Dockerfile string } -type imageDigest string - -func (e *ErrContainerRuntimeUnhealthy) Error() string { - return fmt.Sprintf("container runtime is unhealthy: %s", e.Reason) -} - -type BuildClientProxyImageOptions struct { - BaseImage string - containers.StreamCommandOptions - containers.TimeoutOption - - // Overrides the most recent image builds file path. - // Used primarily for testing purposes. - MostRecentImageBuildsFilePath string -} - -// EnsureClientProxyImage ensures that the client proxy image is built and available -// for use by the client proxy container. -// Returns full image name with tag, and error if any. -func EnsureClientProxyImage( - ctx context.Context, - opts BuildClientProxyImageOptions, - ior containers.ImageOrchestrator, - log logr.Logger, -) (string, error) { - if ctx == nil { - panic("context cannot be nil") - } - if ior == nil { - panic("image orchestrator cannot be nil") - } - - rtStat := ior.CheckStatus(ctx, containers.CachedRuntimeStatusAllowed) - if !rtStat.IsHealthy() { - return "", &ErrContainerRuntimeUnhealthy{Reason: rtStat.Error} - } - - if opts.BaseImage == "" { - opts.BaseImage = DefaultBaseImage - } - +// PrepareClientProxyImageBuild creates the build input for the shared tunnel proxy +// PhysicalContainerImage. +func PrepareClientProxyImageBuild(ctx context.Context) (ClientProxyImageBuildPlan, error) { dcpTunClientPath, clientPathErr := dcptunClientBinaryPath() if clientPathErr != nil { - return "", fmt.Errorf("failed to get path to dcptun client binary: %w", clientPathErr) + return ClientProxyImageBuildPlan{}, fmt.Errorf("failed to get path to dcptun client binary: %w", clientPathErr) } - imageName, imageErr := clientProxyImageName(dcpTunClientPath) - if imageErr != nil { - return "", fmt.Errorf("failed to determine client proxy image: %w", imageErr) + clientBinaryHash, hashErr := computeFileHash(ctx, dcpTunClientPath) + if hashErr != nil { + return ClientProxyImageBuildPlan{}, fmt.Errorf("failed to compute current executable hash: %w", hashErr) } - errKeepWaiting := errors.New("waiting for client proxy image to be built...") - - need, imageCheckErr := resiliency.RetryGet(ctx, backoff.NewConstantBackOff(checkImageBuiltInterval), func() (clientProxyImageNeed, error) { - res, err := shouldBuildClientProxyImage(ctx, opts, ior, imageName, log) - if err != nil { - return proxyImageNeedUnknown, backoff.Permanent(err) - } - if res == proxyImageWait { - return proxyImageWait, errKeepWaiting - } - return res, nil - }) - if imageCheckErr != nil { - return "", fmt.Errorf("failed to check if client proxy image needs to be built: %w", imageCheckErr) + imageName := clientProxyImageName(clientBinaryHash) + dockerfileContent := clientProxyDockerfileContent() + buildContextDigest, digestErr := clientProxyBuildContextDigest(dockerfileContent, clientBinaryHash) + if digestErr != nil { + return ClientProxyImageBuildPlan{}, digestErr } - if need == proxyImageExists { - return imageName, nil - } - - // Create build context with Dockerfile - buildContext, cleanup, contextErr := setupImageBuildContext(dcpTunClientPath, opts) + buildContextArchive, contextErr := setupImageBuildContextArchive(ctx, dcpTunClientPath, dockerfileContent) if contextErr != nil { - return "", fmt.Errorf("failed to create build context: %w", contextErr) - } - defer cleanup() - - // Build the image - buildOptions := containers.BuildImageOptions{ - ContainerBuildContext: &containers.ContainerBuildContext{ - Context: buildContext, - Dockerfile: filepath.Join(buildContext, dockerfileName), - Tags: []string{imageName}, - Labels: []containers.Label{ - { - Key: baseImageDigestLabel, - Value: string(baseImageDigests[opts.BaseImage]), - }, - }, - }, - TimeoutOption: containers.TimeoutOption{ - Timeout: opts.TimeoutOption.Timeout, - }, - } - if opts.StdOutStream != nil { - buildOptions.StdOutStream = opts.StdOutStream - } - if opts.StdErrStream != nil { - buildOptions.StdErrStream = opts.StdErrStream + return ClientProxyImageBuildPlan{}, fmt.Errorf("failed to create build context archive: %w", contextErr) } - buildErr := ior.BuildImage(ctx, buildOptions) - if buildErr != nil { - return "", fmt.Errorf("failed to build client proxy image: %w", buildErr) - } - - return imageName, nil + return ClientProxyImageBuildPlan{ + Image: imageName, + BuildContextDigest: "sha256:" + buildContextDigest, + BuildContextArchive: buildContextArchive, + Dockerfile: dockerfileName, + }, nil } // clientProxyImageName() determines the name of the client proxy container image, // based on the current version of the DCP binaries. -func clientProxyImageName(dcpTunClientPath string) (string, error) { +func clientProxyImageName(clientBinaryHash string) string { imageName := ClientProxyContainerImageNamePrefix tag := version.Version().Version if tag == version.DevelopmentVersion { - // Compute the hash of our binary and append it to the tag - hash, hashErr := computeFileHash(dcpTunClientPath) - if hashErr != nil { - return "", fmt.Errorf("failed to compute current executable hash: %w", hashErr) - } - // 12 characters is more than enough for ensuring that the image with correct binary exists - tag += "_" + hash[:12] - } - - return fmt.Sprintf("%s:%s", imageName, tag), nil -} - -type clientProxyImageNeed string - -const ( - proxyImageNeedUnknown clientProxyImageNeed = "unknown" // An error occurred and the image state is unknown - proxyImageExists clientProxyImageNeed = "exists" // The image already exists and is up to date - proxyImageBuild clientProxyImageNeed = "build" // The image needs to be built - proxyImageWait clientProxyImageNeed = "wait" // The image is being built, wait for it to finish -) - -// Figures out whether existing client proxy image is up to date, or a new one is being built and we need to wait for it, -// or if we are the party that needs to build the image. Returns the "need" for the image, and an error if any. -// If no error occurs, the baseImageDigests map is guaranteed to contain the base image digest for the given base image. -func shouldBuildClientProxyImage( - ctx context.Context, - opts BuildClientProxyImageOptions, - ior containers.ImageOrchestrator, - imageName string, - log logr.Logger, -) (clientProxyImageNeed, error) { - lockErr := imageBuildLock.Lock(ctx) - if lockErr != nil { - return proxyImageNeedUnknown, lockErr // Context expired. - } - defer imageBuildLock.Unlock() - - haveImage, imageCheckErr := haveExistingImage(ctx, opts, ior, imageName, log) - if imageCheckErr != nil { - return proxyImageNeedUnknown, fmt.Errorf("failed to check if the client proxy image exists: %w", imageCheckErr) - } - if haveImage { - // The image exists and is up to date - return proxyImageExists, nil // Image exists and is up to date - } - - var imageBuildsFile *imageBuildsFile - var fileErr error - if opts.MostRecentImageBuildsFilePath != "" { - imageBuildsFile, fileErr = newImageBuildsFile(opts.MostRecentImageBuildsFilePath) - defer func() { _ = imageBuildsFile.Close() }() // best effort - } else { - imageBuildsFile, fileErr = getDefaultImageBuildsFile() - } - if fileErr != nil { - return proxyImageNeedUnknown, fmt.Errorf("failed to get the most recent image builds file: %w", fileErr) - } - - imageBuilds, imageBuildsErr := imageBuildsFile.tryLockAndRead(ctx, defaultImageBuildTimeout) - if imageBuildsErr != nil { - return proxyImageNeedUnknown, fmt.Errorf("failed to check whether other DCP instances are already building the image: %w", imageBuildsErr) - } - defer func() { - // Note: Unlock() is no-op if the file is already unlocked - if unlockErr := imageBuildsFile.Unlock(); unlockErr != nil { - log.Error(unlockErr, "Failed to unlock the most recent image builds file after checking if the image is being built") // Should never happen - } - }() - - baseImageDigest := baseImageDigests[opts.BaseImage] // The map has it because we called haveExistingImage() above - alreadyBuilding := slices.Any(imageBuilds, func(r imageBuildRecord) bool { - return r.ImageName == imageName && r.BaseImageDigest == baseImageDigest - }) - if alreadyBuilding { - log.V(1).Info("The client proxy image is already being built, waiting for it to finish", "ImageName", imageName) - return proxyImageWait, nil + tag += "_" + clientBinaryHash[:12] } - // We need to build the image - imageBuilds = append(imageBuilds, imageBuildRecord{ - ImageName: imageName, - BaseImageDigest: baseImageDigests[opts.BaseImage], - Instance: networking.GetProgramInstanceID(), - Timestamp: time.Now(), - }) - if writeErr := imageBuildsFile.WriteAndUnlock(ctx, imageBuilds); writeErr != nil { - log.Error(writeErr, "Failed to write the most recent image builds file while checking if the image is being built") - } - return proxyImageBuild, nil + return fmt.Sprintf("%s:%s", imageName, tag) } -// Tries to check for existing image and verify its freshness. -// Assumes imageBuildLock is already held. -// Returns true if the image exists and is up to date, false if it does not exist, and an error if any. -// If no error occurs, the baseImageDigests map is guaranteed to contain the base image digest for the given base image. -func haveExistingImage( +func setupImageBuildContextArchive( ctx context.Context, - opts BuildClientProxyImageOptions, - ior containers.ImageOrchestrator, - imageName string, - log logr.Logger, -) (bool, error) { - baseImageDigest, found := baseImageDigests[opts.BaseImage] - if !found { - var digestErr error - baseImageDigest, digestErr = getBestEffortBaseImageDigest(ctx, opts.BaseImage, ior, log) - if digestErr != nil { - return false, digestErr - } - - baseImageDigests[opts.BaseImage] = baseImageDigest - } - - images, err := ior.InspectImages(ctx, containers.InspectImagesOptions{ - Images: []string{imageName}, - }) - if err != nil && !errors.Is(err, containers.ErrNotFound) { - return false, fmt.Errorf("failed to inspect client proxy image %s: %w", imageName, err) + dcpTunClientPath string, + dockerfileContent string, +) (*containers.ContainerBuildContextArchive, error) { + if contextErr := ctx.Err(); contextErr != nil { + return nil, contextErr + } + + randomSuffix, randomSuffixErr := randdata.MakeRandomString(12) + if randomSuffixErr != nil { + return nil, fmt.Errorf("create random build context archive suffix: %w", randomSuffixErr) + } + // The archive lives in the DCP session directory, whose lifetime owns its cleanup. + archiveFile, openArchiveErr := usvc_io.CreateNewTempFile( + fmt.Sprintf("dcptun-build-context-%s.tar", randomSuffix), + osutil.PermissionOnlyOwnerReadWrite, + ) + if openArchiveErr != nil { + return nil, fmt.Errorf("create build context archive: %w", openArchiveErr) + } + archivePath := archiveFile.Name() + cleanup := func() { + _ = archiveFile.Close() + _ = os.Remove(archivePath) + } + + now := time.Now() + tarWriter := usvc_io.NewTarWriterTo(archiveFile) + if writeDockerfileErr := tarWriter.WriteFile( + []byte(dockerfileContent), + dockerfileName, + 0, + 0, + osutil.PermissionOwnerReadWriteOthersRead, + now, + now, + now, + ); writeDockerfileErr != nil { + cleanup() + return nil, fmt.Errorf("write Dockerfile to build context archive: %w", writeDockerfileErr) } - if len(images) == 0 { - return false, nil // Image does not exist + binaryFile, openBinaryErr := usvc_io.OpenFileReadOnly(dcpTunClientPath) + if openBinaryErr != nil { + cleanup() + return nil, fmt.Errorf("open dcptun client binary: %w", openBinaryErr) } - - existing := images[0] - retval := imageDigest(existing.Labels[baseImageDigestLabel]) == baseImageDigest - return retval, nil -} - -func getBestEffortBaseImageDigest( - ctx context.Context, - baseImage string, - ior containers.ImageOrchestrator, - log logr.Logger, -) (imageDigest, error) { - baseImageID, pullErr := ior.PullImage(ctx, containers.PullImageOptions{Image: baseImage}) - if pullErr == nil { - baseImageDigest, inspectErr := inspectBaseImageDigest(ctx, ior, baseImageID) - if inspectErr != nil { - return "", fmt.Errorf("failed to inspect client proxy base image %s: %w", baseImage, inspectErr) - } - - return baseImageDigest, nil + binaryInfo, statBinaryErr := binaryFile.Stat() + if statBinaryErr != nil { + _ = binaryFile.Close() + cleanup() + return nil, fmt.Errorf("stat dcptun client binary: %w", statBinaryErr) + } + copyBinaryCtx, cancelCopyBinary := context.WithCancel(ctx) + copyBinaryErr := tarWriter.CopyFile( + usvc_io.NewContextReader(copyBinaryCtx, binaryFile, false), + binaryInfo.Size(), + ClientBinaryName, + 0, + 0, + os.FileMode(0o755), + binaryInfo.ModTime(), + binaryInfo.ModTime(), + binaryInfo.ModTime(), + ) + cancelCopyBinary() + closeBinaryErr := binaryFile.Close() + if copyBinaryErr != nil { + cleanup() + return nil, fmt.Errorf("copy dcptun client binary to build context archive: %w", copyBinaryErr) } - - baseImageDigest, inspectLocalErr := inspectBaseImageDigest(ctx, ior, baseImage) - if inspectLocalErr != nil { - return "", fmt.Errorf("failed to pull client proxy base image %s and no local copy was available: %w", baseImage, errors.Join(pullErr, inspectLocalErr)) + if closeBinaryErr != nil { + cleanup() + return nil, fmt.Errorf("close dcptun client binary: %w", closeBinaryErr) } - - log.V(1).Info("Failed to pull client proxy base image, using local image", "Image", baseImage, "Error", pullErr.Error()) - return baseImageDigest, nil -} - -func inspectBaseImageDigest( - ctx context.Context, - ior containers.ImageOrchestrator, - imageRef string, -) (imageDigest, error) { - baseImageInspect, inspectErr := ior.InspectImages(ctx, containers.InspectImagesOptions{Images: []string{imageRef}}) - if inspectErr != nil { - return "", inspectErr + if closeTarErr := tarWriter.Close(); closeTarErr != nil { + cleanup() + return nil, fmt.Errorf("finalize build context archive: %w", closeTarErr) } - if len(baseImageInspect) == 0 { - return "", fmt.Errorf("base image %s was not found", imageRef) + if closeArchiveErr := archiveFile.Close(); closeArchiveErr != nil { + cleanup() + return nil, fmt.Errorf("close build context archive: %w", closeArchiveErr) } - inspectedBaseImage := baseImageInspect[0] - if inspectedBaseImage.Digest != "" { - return imageDigest(inspectedBaseImage.Digest), nil - } - if inspectedBaseImage.Id != "" { - return imageDigest(inspectedBaseImage.Id), nil + archiveHash, hashErr := computeFileHash(ctx, archivePath) + if hashErr != nil { + cleanup() + return nil, fmt.Errorf("hash build context archive: %w", hashErr) } - return "", fmt.Errorf("base image %s has no digest or ID", imageRef) + return &containers.ContainerBuildContextArchive{ + Source: archivePath, + SHA256: archiveHash, + }, nil } -// setupImageBuildContext creates a temporary directory for building the client proxy image, -// creates a Dockerfile, and makes the client binary appear in the build context. -// Returns the path to the build context, a cleanup function to remove it, and an error if any. -func setupImageBuildContext(dcpTunClientPath string, opts BuildClientProxyImageOptions) (string, func(), error) { - // Create temporary directory for build context - tempDir, tempDirErr := os.MkdirTemp(usvc_io.DcpTempDir(), "dcptun-build-*") - if tempDirErr != nil { - return "", nil, fmt.Errorf("failed to create temporary directory: %w", tempDirErr) - } - - cleanup := func() { - _ = os.RemoveAll(tempDir) // best effort - } - - // Make the client proxy binary appear the build context - // One would think that creating a symlink would be the right thing to do here, - // but Docker build does not follow symlinks outside the build context. - // So we have to copy the binary instead. - // If this turns to be a performance issue, we can look into using Docker buildkit - // with "additional", external context pointing to the bin folder. - destBinaryPath := filepath.Join(tempDir, ClientBinaryName) - if copyErr := copyFile(dcpTunClientPath, destBinaryPath, osutil.PermissionOnlyOwnerReadWriteExecute); copyErr != nil { - cleanup() - return "", nil, fmt.Errorf("failed to copy binary to build context: %w", copyErr) - } - - dockerfileContent := fmt.Sprintf(` +func clientProxyDockerfileContent() string { + return fmt.Sprintf(` FROM %s # Copy the dcptun client binary @@ -403,49 +203,38 @@ COPY --chmod=0755 %s %[3]s # Set the entrypoint to the dcptun client ENTRYPOINT ["%[3]s"] -`, opts.BaseImage, ClientBinaryName, ClientProxyBinaryPath) - - // Write Dockerfile - dockerfilePath := filepath.Join(tempDir, dockerfileName) - if err := usvc_io.WriteFile(dockerfilePath, []byte(dockerfileContent), osutil.PermissionOnlyOwnerReadWrite); err != nil { - cleanup() - return "", nil, fmt.Errorf("failed to write Dockerfile: %w", err) - } - - return tempDir, cleanup, nil +`, DefaultBaseImage, ClientBinaryName, ClientProxyBinaryPath) } -// copyFile copies a file from src to dst -func copyFile(src, dst string, perm os.FileMode) error { - sourceFile, sourceErr := usvc_io.OpenFileReadOnly(src) - if sourceErr != nil { - return sourceErr +func clientProxyBuildContextDigest(dockerfileContent string, clientBinaryHash string) (string, error) { + buildInputs := struct { + Dockerfile string `json:"dockerfile"` + ClientBinaryHash string `json:"clientBinaryHash"` + }{ + Dockerfile: dockerfileContent, + ClientBinaryHash: clientBinaryHash, } - defer func() { _ = sourceFile.Close() }() - - destFile, destErr := usvc_io.CreateNewFile(dst, perm) - if destErr != nil { - return destErr - } - - if _, copyErr := io.Copy(destFile, sourceFile); copyErr != nil { - _ = destFile.Close() - return copyErr + encodedInputs, encodeErr := json.Marshal(buildInputs) + if encodeErr != nil { + return "", fmt.Errorf("encode client proxy build context inputs: %w", encodeErr) } - return destFile.Close() + return fmt.Sprintf("%x", sha256.Sum256(encodedInputs)), nil } // Computes the SHA256 hash of a given binary file -func computeFileHash(filePath string) (string, error) { - file, openErr := os.Open(filePath) +func computeFileHash(ctx context.Context, filePath string) (string, error) { + file, openErr := usvc_io.OpenFileReadOnly(filePath) if openErr != nil { return "", fmt.Errorf("failed to open binary file %s: %w", filePath, openErr) } defer file.Close() hasher := sha256.New() - if _, copyErr := io.Copy(hasher, file); copyErr != nil { + hashCtx, cancelHash := context.WithCancel(ctx) + _, copyErr := io.Copy(hasher, usvc_io.NewContextReader(hashCtx, file, false)) + cancelHash() + if copyErr != nil { return "", fmt.Errorf("failed to compute hash of binary %s: %w", filePath, copyErr) } diff --git a/internal/dcptun/image_builder_test.go b/internal/dcptun/image_builder_test.go index b0350678..5d40a5b1 100644 --- a/internal/dcptun/image_builder_test.go +++ b/internal/dcptun/image_builder_test.go @@ -6,186 +6,77 @@ package dcptun_test import ( - "errors" + "archive/tar" + "context" + "io" "os" - "path/filepath" "strings" - "sync" "testing" - "time" "github.com/stretchr/testify/require" "github.com/microsoft/dcp/internal/containers" "github.com/microsoft/dcp/internal/dcppaths" "github.com/microsoft/dcp/internal/dcptun" - ctrl_testutil "github.com/microsoft/dcp/internal/testutil/ctrlutil" - "github.com/microsoft/dcp/pkg/slices" - "github.com/microsoft/dcp/pkg/testutil" ) -const clientProxyBaseImageDigestLabel = "com.microsoft.developer.usvc-dev.base-image-digest" - -// Verifies that the client proxy image can be built successfully and has the expected tag prefix. -func TestClientProxyImageBuild(t *testing.T) { +func TestPrepareClientProxyImageBuild(t *testing.T) { t.Parallel() - const testTimeout = 20 * time.Second - ctx, cancel := testutil.GetTestContext(t, testTimeout) - defer cancel() - - log := testutil.NewLogForTesting(t.Name()) - co, coErr := ctrl_testutil.NewTestContainerOrchestrator(ctx, log, ctrl_testutil.TcoOptionNone) - require.NoError(t, coErr, "Failed to create test container orchestrator") - dcppaths.EnableTestPathProbing() - path := filepath.Join(t.TempDir(), t.Name()+".imglist") - defer func() { _ = os.Remove(path) }() // Best effort cleanup - opts := dcptun.BuildClientProxyImageOptions{ - TimeoutOption: containers.TimeoutOption{Timeout: testTimeout / 2}, - MostRecentImageBuildsFilePath: path, - } - imageName, buildErr := dcptun.EnsureClientProxyImage(ctx, opts, co, log) - require.NoError(t, buildErr, "Failed to ensure client proxy image") - - imgs, inspectErr := co.InspectImages(ctx, containers.InspectImagesOptions{ - Images: []string{imageName}, - }) - require.NoError(t, inspectErr, "Failed to inspect client proxy image") - require.Len(t, imgs, 1, "Expected exactly one inspected image") - img := imgs[0] - require.Len(t, img.Tags, 1, "Image should have exactly one tag") + plan, prepareErr := dcptun.PrepareClientProxyImageBuild(context.Background()) + require.NoError(t, prepareErr) const expectedTagPrefix = "dcptun_developer_ms:" - require.True(t, strings.HasPrefix(img.Tags[0], expectedTagPrefix)) - require.Greater(t, len(img.Tags[0]), len(expectedTagPrefix), "Image tag should have a version suffix") -} - -// Verifies that a pull failure for a locally available base image does not prevent building the client proxy image. -func TestClientProxyImageBuildFallsBackToLocalBaseImageWhenPullFails(t *testing.T) { - t.Parallel() - - const testTimeout = 20 * time.Second - ctx, cancel := testutil.GetTestContext(t, testTimeout) - defer cancel() - - log := testutil.NewLogForTesting(t.Name()) - co, coErr := ctrl_testutil.NewTestContainerOrchestrator(ctx, log, ctrl_testutil.TcoOptionNone) - require.NoError(t, coErr, "Failed to create test container orchestrator") - - dcppaths.EnableTestPathProbing() - - baseImage := testBaseImageName(t) - baseImageID, basePullErr := co.PullImage(ctx, containers.PullImageOptions{Image: baseImage}) - require.NoError(t, basePullErr, "Failed to seed local base image") - - baseImages, baseInspectErr := co.InspectImages(ctx, containers.InspectImagesOptions{ - Images: []string{baseImageID}, + require.True(t, strings.HasPrefix(plan.Image, expectedTagPrefix)) + require.Greater(t, len(plan.Image), len(expectedTagPrefix), "Image tag should have a version suffix") + require.NotNil(t, plan.BuildContextArchive) + require.NotEmpty(t, plan.BuildContextDigest) + require.NotEmpty(t, plan.BuildContextArchive.Source) + require.NotEmpty(t, plan.BuildContextArchive.SHA256) + require.NotEqual(t, "sha256:"+plan.BuildContextArchive.SHA256, plan.BuildContextDigest) + require.Empty(t, plan.BuildContextArchive.RawContents) + require.Equal(t, "Dockerfile", plan.Dockerfile) + t.Cleanup(func() { + require.NoError(t, os.Remove(plan.BuildContextArchive.Source)) }) - require.NoError(t, baseInspectErr, "Failed to inspect seeded local base image") - require.Len(t, baseImages, 1, "Expected exactly one seeded local base image") - co.FailPullImage(errors.New("simulated base image pull failure")) - - path := filepath.Join(t.TempDir(), t.Name()+".imglist") - defer func() { _ = os.Remove(path) }() // Best effort cleanup - opts := dcptun.BuildClientProxyImageOptions{ - BaseImage: baseImage, - TimeoutOption: containers.TimeoutOption{Timeout: testTimeout / 2}, - MostRecentImageBuildsFilePath: path, - } - imageName, buildErr := dcptun.EnsureClientProxyImage(ctx, opts, co, log) - require.NoError(t, buildErr, "Failed to ensure client proxy image using local base image") - - imgs, inspectErr := co.InspectImages(ctx, containers.InspectImagesOptions{ - Images: []string{imageName}, + secondPlan, secondPrepareErr := dcptun.PrepareClientProxyImageBuild(context.Background()) + require.NoError(t, secondPrepareErr) + require.Equal(t, plan.BuildContextDigest, secondPlan.BuildContextDigest) + t.Cleanup(func() { + require.NoError(t, os.Remove(secondPlan.BuildContextArchive.Source)) }) - require.NoError(t, inspectErr, "Failed to inspect client proxy image") - require.Len(t, imgs, 1, "Expected exactly one inspected image") - require.Equal(t, baseImages[0].Digest, imgs[0].Labels[clientProxyBaseImageDigestLabel]) -} -// Verifies that a pull failure still fails when there is no local base image to use. -func TestClientProxyImageBuildFailsWhenPullFailsAndBaseImageIsUnavailable(t *testing.T) { - t.Parallel() - - const testTimeout = 20 * time.Second - ctx, cancel := testutil.GetTestContext(t, testTimeout) - defer cancel() - - log := testutil.NewLogForTesting(t.Name()) - co, coErr := ctrl_testutil.NewTestContainerOrchestrator(ctx, log, ctrl_testutil.TcoOptionNone) - require.NoError(t, coErr, "Failed to create test container orchestrator") - - dcppaths.EnableTestPathProbing() - co.FailPullImage(errors.New("simulated base image pull failure")) - - path := filepath.Join(t.TempDir(), t.Name()+".imglist") - defer func() { _ = os.Remove(path) }() // Best effort cleanup - opts := dcptun.BuildClientProxyImageOptions{ - BaseImage: testBaseImageName(t), - TimeoutOption: containers.TimeoutOption{Timeout: testTimeout / 2}, - MostRecentImageBuildsFilePath: path, + archiveReader, openErr := containers.OpenBuildContextArchive(plan.BuildContextArchive) + require.NoError(t, openErr) + defer archiveReader.Close() + + entries := map[string][]byte{} + tarReader := tar.NewReader(archiveReader) + for { + header, nextErr := tarReader.Next() + if nextErr == io.EOF { + break + } + require.NoError(t, nextErr) + contents, readErr := io.ReadAll(tarReader) + require.NoError(t, readErr) + entries[header.Name] = contents } - _, buildErr := dcptun.EnsureClientProxyImage(ctx, opts, co, log) - require.Error(t, buildErr, "Expected client proxy image build to fail without a local base image") - require.ErrorContains(t, buildErr, "no local copy was available") + require.Contains(t, string(entries["Dockerfile"]), "FROM "+dcptun.DefaultBaseImage) + require.NotEmpty(t, entries[dcptun.ClientBinaryName]) } -// Verifies that the client proxy image is built once even if EnsureClientProxyImage() is called concurrently multiple times. -func TestConcurrentClientProxyImageBuild(t *testing.T) { +func TestPrepareClientProxyImageBuildHonorsCancellation(t *testing.T) { t.Parallel() - const testTimeout = 20 * time.Second - ctx, cancel := testutil.GetTestContext(t, testTimeout) - defer cancel() - - log := testutil.NewLogForTesting(t.Name()) - co, coErr := ctrl_testutil.NewTestContainerOrchestrator(ctx, log, ctrl_testutil.TcoOptionNone) - require.NoError(t, coErr, "Failed to create test container orchestrator") - dcppaths.EnableTestPathProbing() + ctx, cancel := context.WithCancel(context.Background()) + cancel() - path := filepath.Join(t.TempDir(), t.Name()+".imglist") - defer func() { _ = os.Remove(path) }() // Best effort cleanup - opts := dcptun.BuildClientProxyImageOptions{ - TimeoutOption: containers.TimeoutOption{Timeout: testTimeout / 2}, - MostRecentImageBuildsFilePath: path, - } - - const numConcurrentBuilds = 5 - wg := sync.WaitGroup{} - wg.Add(numConcurrentBuilds) - - type imgBuildRes struct { - imageName string - buildErr error - } - rchan := make(chan imgBuildRes, numConcurrentBuilds) - - for i := 0; i < numConcurrentBuilds; i++ { - go func() { - defer wg.Done() - imageName, buildErr := dcptun.EnsureClientProxyImage(ctx, opts, co, log) - rchan <- imgBuildRes{imageName, buildErr} - }() - } - - wg.Wait() - var results []imgBuildRes - for i := 0; i < numConcurrentBuilds; i++ { - res := <-rchan - require.NoError(t, res.buildErr, "Failed to ensure client proxy image in concurrent build") - require.NotEmpty(t, res.imageName, "Image name should not be empty") - results = append(results, res) - } - require.Truef(t, slices.All(results, func(r imgBuildRes) bool { - return r.imageName == results[0].imageName - }), "All concurrent builds should return the same image name, but results are: %v", results) -} - -func testBaseImageName(t *testing.T) string { - return "example.test/" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "-")) + ":latest" + _, prepareErr := dcptun.PrepareClientProxyImageBuild(ctx) + require.ErrorIs(t, prepareErr, context.Canceled) } diff --git a/internal/dcptun/mr_image_builds.go b/internal/dcptun/mr_image_builds.go deleted file mode 100644 index 9488c704..00000000 --- a/internal/dcptun/mr_image_builds.go +++ /dev/null @@ -1,138 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package dcptun - -import ( - "bytes" - "context" - "fmt" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/microsoft/dcp/internal/dcppaths" - "github.com/microsoft/dcp/internal/lockfile" - "github.com/microsoft/dcp/pkg/osutil" - "github.com/microsoft/dcp/pkg/slices" -) - -const ( - imageBuildsRecordPattern = "%s %s %s %s" // imageName baseImageDigest programInstanceID timestamp -) - -var ( - getDefaultImageBuildsFile = sync.OnceValues(createPackageImageBuildsFile) -) - -// A record representing one of the most recent client proxy image builds. -type imageBuildRecord struct { - ImageName string // The name and tag of the image - BaseImageDigest imageDigest // The digest of the base image used for the build - Instance string // The DCP instance ID that is building (or should have built) the image - Timestamp time.Time -} - -type imageBuildRecordMarshaller struct{} - -func (_ imageBuildRecordMarshaller) Unmarshal(line []byte) (imageBuildRecord, error) { - var imageName, instance, timestampStr string - var baseImageDigest imageDigest - n, scanErr := fmt.Fscanf(bytes.NewReader(line), imageBuildsRecordPattern, &imageName, &baseImageDigest, &instance, ×tampStr) - if scanErr != nil { - return imageBuildRecord{}, fmt.Errorf("the most recent image builds file is corrupted (invalid record, the line read was '%s'): %w", line, scanErr) - } - if n != 4 { - return imageBuildRecord{}, fmt.Errorf("the most recent image builds file is corrupted (invalid record, expected 4 fields but got %d, the line was '%s')", n, line) - } - - timestamp, timestampParseErr := time.Parse(time.RFC3339Nano, timestampStr) - if timestampParseErr != nil { - return imageBuildRecord{}, fmt.Errorf("the most recent image builds file is corrupted (invalid timestamp found, the timestamp string was '%s')", timestampStr) - } - - imageName = strings.TrimSpace(imageName) - baseImageDigest = imageDigest(strings.TrimSpace(string(baseImageDigest))) - instance = strings.TrimSpace(instance) - - if imageName == "" { - return imageBuildRecord{}, fmt.Errorf("the most recent image builds file is corrupted (image name cannot be empty, the line was '%s')", line) - } - if baseImageDigest == "" { - return imageBuildRecord{}, fmt.Errorf("the most recent image builds file is corrupted (base image digest cannot be empty, the line was '%s')", line) - } - if instance == "" { - return imageBuildRecord{}, fmt.Errorf("the most recent image builds file is corrupted (instance ID cannot be empty, the line was '%s')", line) - } - - return imageBuildRecord{ - ImageName: imageName, - BaseImageDigest: baseImageDigest, - Instance: instance, - Timestamp: timestamp, - }, nil -} - -func (_ imageBuildRecordMarshaller) Marshal(record imageBuildRecord) []byte { - return fmt.Appendf(nil, imageBuildsRecordPattern, - strings.TrimSpace(record.ImageName), - strings.TrimSpace(string(record.BaseImageDigest)), - strings.TrimSpace(record.Instance), - record.Timestamp.Format(time.RFC3339Nano), - ) -} - -type imageBuildsFile struct { - lockfile.RecordFile[imageBuildRecord] -} - -func newImageBuildsFile(path string) (*imageBuildsFile, error) { - recordFile, err := lockfile.NewRecordFile(path, imageBuildRecordMarshaller{}) - if err != nil { - return nil, err - } - return &imageBuildsFile{ - RecordFile: *recordFile, - }, nil -} - -// Returns the content of the most recent image builds file, with expired records removed. -// The file is left locked if the operation is successful. -// If an error occurs, the file is truncated and unlocked. -func (imf *imageBuildsFile) tryLockAndRead(ctx context.Context, ttl time.Duration) ([]imageBuildRecord, error) { - records, readErr := imf.TryLockAndRead(ctx) - if readErr != nil { - return nil, readErr - } - - records = slices.Select(records, func(r imageBuildRecord) bool { - return time.Since(r.Timestamp) < ttl - }) - - return records, nil -} - -func createPackageImageBuildsFile() (*imageBuildsFile, error) { - dcpFolder, dcpFolderErr := dcppaths.EnsureUserDcpDir() - if dcpFolderErr != nil { - return nil, dcpFolderErr - } - - isAdmin, isAdminErr := osutil.IsAdmin() - if isAdminErr != nil { - return nil, isAdminErr - } - - var filePath string - if isAdmin { - filePath = filepath.Join(dcpFolder, "mrImageBuilds.elevated.list") - } else { - filePath = filepath.Join(dcpFolder, "mrImageBuilds.list") - } - - packageImageBuildsFile, creationErr := newImageBuildsFile(filePath) - return packageImageBuildsFile, creationErr -} diff --git a/internal/docker/cli_orchestrator.go b/internal/docker/cli_orchestrator.go index a9516f84..10aa381d 100644 --- a/internal/docker/cli_orchestrator.go +++ b/internal/docker/cli_orchestrator.go @@ -13,7 +13,6 @@ import ( "errors" "fmt" "io" - "os" "os/exec" "regexp" "strconv" @@ -69,7 +68,6 @@ var ( // We allow up to a minute for diagnostic commands to finish as we'd rather wait a bit longer than miss information. diagnosticDockerCommandTimeout = 1 * time.Minute - defaultBuildImageTimeout = 10 * time.Minute defaultPullImageTimeout = 10 * time.Minute defaultCreateContainerTimeout = 10 * time.Minute defaultRunContainerTimeout = 10 * time.Minute @@ -473,96 +471,9 @@ func (dco *DockerCliOrchestrator) RemoveVolumes(ctx context.Context, options con } func (dco *DockerCliOrchestrator) BuildImage(ctx context.Context, options containers.BuildImageOptions) error { - args := []string{"build"} - - if options.Dockerfile != "" { - args = append(args, "-f", options.Dockerfile) - } - - // Should base images be updated even if they are already present locally? - if options.Pull { - args = append(args, "--pull") - } - - // If specified, the ID of the image will be written to this file - if options.IidFile != "" { - args = append(args, "--iidfile", options.IidFile) - } - - // Apply all tags specified in the build context to the image - for _, tag := range options.Tags { - args = append(args, "-t", tag) - } - - // Apply all specified build arguments - for _, buildArg := range options.Args { - if buildArg.Value != "" { - args = append(args, "--build-arg", fmt.Sprintf("%s=%s", buildArg.Name, buildArg.Value)) - } else { - args = append(args, "--build-arg", buildArg.Name) - } - } - - // Secret values that need to be applied to the build command environment - secretEnvironment := map[string]string{} - - // Apply all specified build secrets - for _, secret := range options.Secrets { - switch secret.Type { - case containers.FileSecret, "": - args = append(args, "--secret", fmt.Sprintf("id=%s,src=%s", secret.ID, secret.Source)) - case containers.EnvSecret: - if secret.Source != "" { - args = append(args, "--secret", fmt.Sprintf("id=%s,env=%s", secret.ID, secret.Source)) - if secret.Value != "" { - secretEnvironment[secret.Source] = secret.Value - } - } else { - args = append(args, "--secret", fmt.Sprintf("id=%s,env=%s", secret.ID, secret.ID)) - if secret.Value != "" { - secretEnvironment[secret.ID] = secret.Value - } - } - } - } - - // If a build stage is given, use it - if options.Stage != "" { - args = append(args, "--target", options.Stage) - } - - // Apply any specified labels - for _, label := range options.Labels { - args = append(args, "--label", fmt.Sprintf("%s=%s", label.Key, label.Value)) - } - - // If a target platform is specified, build for that platform - if options.Platform != "" { - args = append(args, "--platform", options.Platform) - } - - // Enable plain output mode (Docker only) - args = append(args, "--progress", "plain") - - // Append the build context argument - args = append(args, options.Context) - - cmd := makeDockerCommand(args...) - - // Append secret environment - cmd.Env = os.Environ() - for secretName, secretValue := range secretEnvironment { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", secretName, secretValue)) - } - - // Building an image can take a long time to finish, particularly if any base images are not available locally. - // Use a much longer timeout than for other commands. - if options.Timeout == 0 { - options.Timeout = defaultBuildImageTimeout - } - _, errBuf, err := dco.runBufferedDockerCommand(ctx, "BuildImage", cmd, options.StdOutStream, options.StdErrStream, options.Timeout) - if err != nil { - return errors.Join(err, normalizeCliErrors(errBuf)) + errBuf, buildErr := containers.BuildImageImpl(ctx, options, dco, "--progress", "plain") + if buildErr != nil { + return errors.Join(buildErr, normalizeCliErrors(errBuf)) } return nil diff --git a/internal/podman/cli_orchestrator.go b/internal/podman/cli_orchestrator.go index 5d5a797b..cf9879c1 100644 --- a/internal/podman/cli_orchestrator.go +++ b/internal/podman/cli_orchestrator.go @@ -13,7 +13,6 @@ import ( "errors" "fmt" "io" - "os" "os/exec" "regexp" "runtime" @@ -67,7 +66,6 @@ var ( // We allow up to a minute for diagnostic commands to finish as we'd rather wait a bit longer than miss information. diagnosticPodmanCommandTimeout = 1 * time.Minute - defaultBuildImageTimeout = 10 * time.Minute defaultPullImageTimeout = 10 * time.Minute defaultCreateContainerTimeout = 10 * time.Minute defaultRunContainerTimeout = 10 * time.Minute @@ -362,93 +360,9 @@ func (pco *PodmanCliOrchestrator) RemoveVolumes(ctx context.Context, options con } func (pco *PodmanCliOrchestrator) BuildImage(ctx context.Context, options containers.BuildImageOptions) error { - args := []string{"build"} - - if options.Dockerfile != "" { - args = append(args, "-f", options.Dockerfile) - } - - // Should base images be updated even if they are already present locally? - if options.Pull { - args = append(args, "--pull") - } - - // If specified, the ID of the image will be written to this file - if options.IidFile != "" { - args = append(args, "--iidfile", options.IidFile) - } - - // Apply all tags specified in the build context to the image - for _, tag := range options.Tags { - args = append(args, "-t", tag) - } - - // Apply all specified build arguments - for _, buildArg := range options.Args { - if buildArg.Value != "" { - args = append(args, "--build-arg", fmt.Sprintf("%s=%s", buildArg.Name, buildArg.Value)) - } else { - args = append(args, "--build-arg", buildArg.Name) - } - } - - // Secret values that need to be applied to the build command environment - secretEnvironment := map[string]string{} - - // Apply all specified build secrets - for _, secret := range options.Secrets { - switch secret.Type { - case containers.FileSecret, "": - args = append(args, "--secret", fmt.Sprintf("id=%s,src=%s", secret.ID, secret.Source)) - case containers.EnvSecret: - if secret.Source != "" { - args = append(args, "--secret", fmt.Sprintf("id=%s,env=%s", secret.ID, secret.Source)) - if secret.Value != "" { - secretEnvironment[secret.Source] = secret.Value - } - } else { - args = append(args, "--secret", fmt.Sprintf("id=%s,env=%s", secret.ID, secret.ID)) - if secret.Value != "" { - secretEnvironment[secret.ID] = secret.Value - } - } - } - } - - // If a build stage is given, use it - if options.Stage != "" { - args = append(args, "--target", options.Stage) - } - - // Apply any specified labels - for _, label := range options.Labels { - args = append(args, "--label", fmt.Sprintf("%s=%s", label.Key, label.Value)) - } - - // If a target platform is specified, build for that platform - if options.Platform != "" { - args = append(args, "--platform", options.Platform) - } - - // Append the build context argument - args = append(args, options.Context) - - cmd := makePodmanCommand(args...) - - // Append secret environment - cmd.Env = os.Environ() - for secretName, secretValue := range secretEnvironment { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", secretName, secretValue)) - } - - // Building an image can take a long time to finish, particularly if any base images are not available locally. - // Use a much longer timeout than for other commands. - if options.Timeout == 0 { - options.Timeout = defaultBuildImageTimeout - } - _, errBuf, err := pco.runBufferedPodmanCommand(ctx, "BuildImage", cmd, options.StdOutStream, options.StdErrStream, options.Timeout) - if err != nil { - return errors.Join(err, normalizeCliErrors(errBuf)) + errBuf, buildErr := containers.BuildImageImpl(ctx, options, pco) + if buildErr != nil { + return errors.Join(buildErr, normalizeCliErrors(errBuf)) } return nil diff --git a/internal/resourcecleanup/resources.go b/internal/resourcecleanup/resources.go index 76a18cb0..54d1c585 100644 --- a/internal/resourcecleanup/resources.go +++ b/internal/resourcecleanup/resources.go @@ -74,8 +74,14 @@ var NamespaceResources = []*CleanupResource{ { GVR: (&apiv2.PhysicalProcess{}).GetGroupVersionResource(), }, + { + GVR: (&apiv2.PhysicalContainerNetworkConnection{}).GetGroupVersionResource(), + }, { GVR: (&apiv2.PhysicalContainer{}).GetGroupVersionResource(), + CleanUpAfter: []schema.GroupVersionResource{ + (&apiv2.PhysicalContainerNetworkConnection{}).GetGroupVersionResource(), + }, }, { GVR: (&apiv2.PhysicalContainerImage{}).GetGroupVersionResource(), diff --git a/internal/resourcecleanup/resources_test.go b/internal/resourcecleanup/resources_test.go index d5e38a98..5e443ce3 100644 --- a/internal/resourcecleanup/resources_test.go +++ b/internal/resourcecleanup/resources_test.go @@ -28,22 +28,25 @@ func TestShutdownResourcesIncludeV1AndV2Namespace(t *testing.T) { require.NotContains(t, shutdownResourceGVRs, (&apiv2.PhysicalProcess{}).GetGroupVersionResource()) } -func TestNamespaceResourcesCleanPhysicalContainersFirst(t *testing.T) { +func TestNamespaceResourcesCleanPhysicalConnectionsBeforeContainers(t *testing.T) { namespaceResourcesByGVR := cleanupResourcesByGVR(NamespaceResources) physicalContainerGVR := (&apiv2.PhysicalContainer{}).GetGroupVersionResource() physicalContainerImageGVR := (&apiv2.PhysicalContainerImage{}).GetGroupVersionResource() physicalContainerNetworkGVR := (&apiv2.PhysicalContainerNetwork{}).GetGroupVersionResource() + physicalContainerNetworkConnectionGVR := (&apiv2.PhysicalContainerNetworkConnection{}).GetGroupVersionResource() physicalContainerVolumeGVR := (&apiv2.PhysicalContainerVolume{}).GetGroupVersionResource() physicalProcessGVR := (&apiv2.PhysicalProcess{}).GetGroupVersionResource() - require.Len(t, namespaceResourcesByGVR, 5) + require.Len(t, namespaceResourcesByGVR, 6) require.Contains(t, namespaceResourcesByGVR, physicalContainerGVR) require.Contains(t, namespaceResourcesByGVR, physicalContainerImageGVR) require.Contains(t, namespaceResourcesByGVR, physicalContainerNetworkGVR) + require.Contains(t, namespaceResourcesByGVR, physicalContainerNetworkConnectionGVR) require.Contains(t, namespaceResourcesByGVR, physicalContainerVolumeGVR) require.Contains(t, namespaceResourcesByGVR, physicalProcessGVR) require.Empty(t, namespaceResourcesByGVR[physicalProcessGVR].CleanUpAfter) - require.Empty(t, namespaceResourcesByGVR[physicalContainerGVR].CleanUpAfter) + require.Empty(t, namespaceResourcesByGVR[physicalContainerNetworkConnectionGVR].CleanUpAfter) + require.Contains(t, namespaceResourcesByGVR[physicalContainerGVR].CleanUpAfter, physicalContainerNetworkConnectionGVR) require.Contains(t, namespaceResourcesByGVR[physicalContainerImageGVR].CleanUpAfter, physicalContainerGVR) // A network cannot be removed while containers are still attached to it. require.Contains(t, namespaceResourcesByGVR[physicalContainerNetworkGVR].CleanUpAfter, physicalContainerGVR) diff --git a/internal/testutil/ctrlutil/test_container_orchestrator.go b/internal/testutil/ctrlutil/test_container_orchestrator.go index 9ab1c47e..ec575058 100644 --- a/internal/testutil/ctrlutil/test_container_orchestrator.go +++ b/internal/testutil/ctrlutil/test_container_orchestrator.go @@ -18,6 +18,7 @@ import ( "fmt" "io" "io/fs" + std_maps "maps" "net" "net/http" "os" @@ -1584,6 +1585,16 @@ func (to *TestContainerOrchestrator) BuildImage(ctx context.Context, options con return errRuntimeUnhealthy } + for _, existingImage := range to.images { + remainingTags := make([]string, 0, len(existingImage.tags)) + for _, existingTag := range existingImage.tags { + if !slices.Contains(options.Tags, existingTag) { + remainingTags = append(remainingTags, existingTag) + } + } + existingImage.tags = remainingTags + } + guid := uuid.New().String() omitImageID := slices.Any(options.Tags, func(tag string) bool { key := testContainerOperationKey{operation: operationBuildImage, resourceID: tag} @@ -1592,7 +1603,7 @@ func (to *TestContainerOrchestrator) BuildImage(ctx context.Context, options con image := &testImage{ id: guid, digest: toDigest(sha256.Sum256([]byte(guid))), - tags: options.Tags, + tags: std_slices.Clone(options.Tags), secrets: map[string]string{}, labels: maps.SliceToMap(options.Labels, func(label commonapi.Label) (string, string) { return label.Key, label.Value @@ -1647,8 +1658,8 @@ func (to *TestContainerOrchestrator) InspectImages(ctx context.Context, options result = append(result, containers.InspectedImage{ Id: image.id, - Labels: image.labels, - Tags: image.tags, + Labels: std_maps.Clone(image.labels), + Tags: std_slices.Clone(image.tags), Digest: image.digest, }) } @@ -1778,6 +1789,17 @@ func (to *TestContainerOrchestrator) findImage(id string) (*testImage, bool) { } } +func (to *TestContainerOrchestrator) containerMatches(container *testContainer, name string) bool { + if container.matches(name) || strings.HasPrefix(container.Image, name) { + return true + } + + image, found := to.findImage(container.Image) + return found && slices.Any(image.tags, func(tag string) bool { + return strings.HasPrefix(tag, name) + }) +} + func toDigest(sha [32]byte) string { return fmt.Sprintf("sha256:%s", hex.EncodeToString(sha[:])) } @@ -2072,13 +2094,13 @@ func (to *TestContainerOrchestrator) doStartContainer(ctx context.Context, conta } for name, exit := range to.containersToFail { - if container.matches(name) || strings.HasPrefix(container.Image, name) { + if to.containerMatches(container, name) { return container.ID, nil, streamIfPossible(fmt.Errorf("container failed to start: %s", exit.stdErr)) } } for name, containerStartupLogs := range to.startupLogs { - if container.matches(name) || strings.HasPrefix(container.Image, name) { + if to.containerMatches(container, name) { var startupLogsWriteErrors error if len(containerStartupLogs.stdout) > 0 && streamOptions.StdOutStream != nil { @@ -2907,6 +2929,49 @@ func (to *TestContainerOrchestrator) SimulateContainerStatus(ctx context.Context return containers.ErrNotFound } +func (to *TestContainerOrchestrator) SimulateContainerPortMappings( + ctx context.Context, + name string, + portMappings containers.InspectedContainerPortMapping, +) error { + to.mutex.Lock() + defer to.mutex.Unlock() + + if ctx.Err() != nil { + return ctx.Err() + } + if !to.runtimeHealthy { + return errRuntimeUnhealthy + } + + for _, container := range to.containers { + if !container.matches(name) { + continue + } + + container.Ports = maps.Map[string, []containers.InspectedContainerHostPortConfig, []TestContainerPortConfig]( + portMappings, + func(_ string, hostPorts []containers.InspectedContainerHostPortConfig) []TestContainerPortConfig { + return slices.Map[TestContainerPortConfig](hostPorts, func(hostPort containers.InspectedContainerHostPortConfig) TestContainerPortConfig { + return TestContainerPortConfig{InspectedContainerHostPortConfig: hostPort} + }) + }, + ) + to.containers[container.ID] = container + to.containerEventsWatcher.Notify(containers.EventMessage{ + Source: containers.EventSourceContainer, + Action: containers.EventActionUpdate, + Actor: containers.EventActor{ID: container.ID}, + Attributes: map[string]string{ + ContainerNameAttribute: name, + }, + }) + return nil + } + + return containers.ErrNotFound +} + func (to *TestContainerOrchestrator) SimulateContainerRestart(ctx context.Context, name string) error { to.mutex.Lock() defer to.mutex.Unlock() diff --git a/internal/testutil/ctrlutil/test_container_orchestrator_test.go b/internal/testutil/ctrlutil/test_container_orchestrator_test.go index f21a91f8..1a31092b 100644 --- a/internal/testutil/ctrlutil/test_container_orchestrator_test.go +++ b/internal/testutil/ctrlutil/test_container_orchestrator_test.go @@ -48,6 +48,78 @@ func TestIsBuiltInNetwork(t *testing.T) { require.False(t, orchestrator.IsBuiltInNetwork("application")) } +func TestFailMatchingContainersMatchesBuiltImageTag(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + orchestrator, orchestratorErr := NewTestContainerOrchestrator(ctx, logr.Discard(), TcoOptionNone) + require.NoError(t, orchestratorErr) + defer func() { + require.NoError(t, orchestrator.Close()) + }() + + const imageTag = "test-image:latest" + buildErr := orchestrator.BuildImage(ctx, containers.BuildImageOptions{ + ContainerBuildContext: &containers.ContainerBuildContext{ + Tags: []string{imageTag}, + }, + }) + require.NoError(t, buildErr) + + images, inspectErr := orchestrator.InspectImages(ctx, containers.InspectImagesOptions{ + Images: []string{imageTag}, + }) + require.NoError(t, inspectErr) + require.Len(t, images, 1) + + containerID, createErr := orchestrator.CreateContainer(ctx, containers.CreateContainerOptions{ + Name: "test-container", + Image: images[0].Id, + }) + require.NoError(t, createErr) + + orchestrator.FailMatchingContainers(ctx, "test-image", 1, "expected startup failure") + _, startErr := orchestrator.StartContainers(ctx, containers.StartContainersOptions{ + Containers: []string{containerID}, + }) + require.ErrorContains(t, startErr, "expected startup failure") +} + +func TestInspectImagesReturnsSnapshot(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + orchestrator, orchestratorErr := NewTestContainerOrchestrator(ctx, logr.Discard(), TcoOptionNone) + require.NoError(t, orchestratorErr) + t.Cleanup(func() { + require.NoError(t, orchestrator.Close()) + }) + + const imageTag = "snapshot-image:latest" + buildErr := orchestrator.BuildImage(ctx, containers.BuildImageOptions{ + ContainerBuildContext: &containers.ContainerBuildContext{ + Tags: []string{imageTag}, + Labels: []containers.Label{{Key: "snapshot-label", Value: "original"}}, + }, + }) + require.NoError(t, buildErr) + + images, inspectErr := orchestrator.InspectImages(ctx, containers.InspectImagesOptions{Images: []string{imageTag}}) + require.NoError(t, inspectErr) + require.Len(t, images, 1) + imageID := images[0].Id + images[0].Tags[0] = "modified:latest" + images[0].Labels["snapshot-label"] = "modified" + + inspectedAgain, inspectAgainErr := orchestrator.InspectImages(ctx, containers.InspectImagesOptions{Images: []string{imageID}}) + require.NoError(t, inspectAgainErr) + require.Len(t, inspectedAgain, 1) + require.Equal(t, []string{imageTag}, inspectedAgain[0].Tags) + require.Equal(t, "original", inspectedAgain[0].Labels["snapshot-label"]) +} + func TestRemoveImagesReturnsRequestedIdentifiers(t *testing.T) { t.Parallel() diff --git a/pkg/generated/openapi/zz_generated.openapi.go b/pkg/generated/openapi/zz_generated.openapi.go index 3e5f1b87..fa8f6d9f 100644 --- a/pkg/generated/openapi/zz_generated.openapi.go +++ b/pkg/generated/openapi/zz_generated.openapi.go @@ -23,162 +23,166 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - v1.AmbientEnvironment{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_AmbientEnvironment(ref), - v1.Container{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_Container(ref), - v1.ContainerBuildContext{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerBuildContext(ref), - v1.ContainerBuildSecret{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerBuildSecret(ref), - v1.ContainerExec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerExec(ref), - v1.ContainerExecList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerExecList(ref), - v1.ContainerExecSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerExecSpec(ref), - v1.ContainerExecStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerExecStatus(ref), - v1.ContainerLabel{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerLabel(ref), - v1.ContainerList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerList(ref), - v1.ContainerNetwork{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetwork(ref), - v1.ContainerNetworkConnection{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkConnection(ref), - v1.ContainerNetworkConnectionConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkConnectionConfig(ref), - v1.ContainerNetworkConnectionList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkConnectionList(ref), - v1.ContainerNetworkConnectionSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkConnectionSpec(ref), - v1.ContainerNetworkList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkList(ref), - v1.ContainerNetworkSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkSpec(ref), - v1.ContainerNetworkStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkStatus(ref), - v1.ContainerNetworkTunnelProxy{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkTunnelProxy(ref), - v1.ContainerNetworkTunnelProxyList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkTunnelProxyList(ref), - v1.ContainerNetworkTunnelProxySpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkTunnelProxySpec(ref), - v1.ContainerNetworkTunnelProxyStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkTunnelProxyStatus(ref), - v1.ContainerPemCertificates{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerPemCertificates(ref), - v1.ContainerPort{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerPort(ref), - v1.ContainerSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerSpec(ref), - v1.ContainerStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerStatus(ref), - v1.ContainerVolume{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerVolume(ref), - v1.ContainerVolumeList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerVolumeList(ref), - v1.ContainerVolumeSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerVolumeSpec(ref), - v1.ContainerVolumeStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerVolumeStatus(ref), - v1.CreateFileSystem{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_CreateFileSystem(ref), - v1.Endpoint{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_Endpoint(ref), - v1.EndpointList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_EndpointList(ref), - v1.EndpointSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_EndpointSpec(ref), - v1.EndpointStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_EndpointStatus(ref), - v1.Executable{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_Executable(ref), - v1.ExecutableList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableList(ref), - v1.ExecutablePemCertificates{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutablePemCertificates(ref), - v1.ExecutableProbe{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableProbe(ref), - v1.ExecutableReplicaSet{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableReplicaSet(ref), - v1.ExecutableReplicaSetList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableReplicaSetList(ref), - v1.ExecutableReplicaSetSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableReplicaSetSpec(ref), - v1.ExecutableReplicaSetStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableReplicaSetStatus(ref), - v1.ExecutableSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableSpec(ref), - v1.ExecutableStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableStatus(ref), - v1.ExecutableTemplate{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableTemplate(ref), - v1.FileSystemEntry{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_FileSystemEntry(ref), - v1.HealthProbe{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HealthProbe(ref), - v1.HealthProbeResult{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HealthProbeResult(ref), - v1.HealthProbeSchedule{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HealthProbeSchedule(ref), - v1.HttpHeader{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HttpHeader(ref), - v1.HttpProbe{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HttpProbe(ref), - v1.ImageLayer{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ImageLayer(ref), - v1.LogOptions{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_LogOptions(ref), - v1.LogStreamer{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_LogStreamer(ref), - v1.PemCertificate{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_PemCertificate(ref), - v1.Service{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_Service(ref), - v1.ServiceList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ServiceList(ref), - v1.ServiceSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ServiceSpec(ref), - v1.ServiceStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ServiceStatus(ref), - v1.TerminalSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_TerminalSpec(ref), - v1.TunnelConfiguration{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_TunnelConfiguration(ref), - v1.TunnelStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_TunnelStatus(ref), - v1.VolumeMount{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_VolumeMount(ref), - v2.ContainerBuildContext{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_ContainerBuildContext(ref), - v2.ContainerBuildSecret{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_ContainerBuildSecret(ref), - v2.ContainerNetworkConnectionConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_ContainerNetworkConnectionConfig(ref), - v2.ContainerPort{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_ContainerPort(ref), - v2.CreateFileSystem{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_CreateFileSystem(ref), - v2.FileSystemEntry{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_FileSystemEntry(ref), - v2.Namespace{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_Namespace(ref), - v2.NamespaceList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_NamespaceList(ref), - v2.NamespaceStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_NamespaceStatus(ref), - v2.PhysicalContainer{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainer(ref), - v2.PhysicalContainerConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerConfig(ref), - v2.PhysicalContainerImage{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImage(ref), - v2.PhysicalContainerImageConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImageConfig(ref), - v2.PhysicalContainerImageList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImageList(ref), - v2.PhysicalContainerImageSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImageSpec(ref), - v2.PhysicalContainerImageStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImageStatus(ref), - v2.PhysicalContainerList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerList(ref), - v2.PhysicalContainerNetwork{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetwork(ref), - v2.PhysicalContainerNetworkConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkConfig(ref), - v2.PhysicalContainerNetworkList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkList(ref), - v2.PhysicalContainerNetworkSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkSpec(ref), - v2.PhysicalContainerNetworkStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkStatus(ref), - v2.PhysicalContainerPortMapping{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerPortMapping(ref), - v2.PhysicalContainerSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerSpec(ref), - v2.PhysicalContainerStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerStatus(ref), - v2.PhysicalContainerVolume{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolume(ref), - v2.PhysicalContainerVolumeConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolumeConfig(ref), - v2.PhysicalContainerVolumeList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolumeList(ref), - v2.PhysicalContainerVolumeSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolumeSpec(ref), - v2.PhysicalContainerVolumeStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolumeStatus(ref), - v2.PhysicalProcess{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcess(ref), - v2.PhysicalProcessConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcessConfig(ref), - v2.PhysicalProcessList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcessList(ref), - v2.PhysicalProcessSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcessSpec(ref), - v2.PhysicalProcessStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcessStatus(ref), - v2.VolumeMount{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_VolumeMount(ref), - commonapi.EnvVar{}.OpenAPIModelName(): schema_microsoft_dcp_pkg_commonapi_EnvVar(ref), - commonapi.Label{}.OpenAPIModelName(): schema_microsoft_dcp_pkg_commonapi_Label(ref), - metav1.APIGroup{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIGroup(ref), - metav1.APIGroupList{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIGroupList(ref), - metav1.APIResource{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIResource(ref), - metav1.APIResourceList{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIResourceList(ref), - metav1.APIVersions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIVersions(ref), - metav1.ApplyOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ApplyOptions(ref), - metav1.Condition{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Condition(ref), - metav1.CreateOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_CreateOptions(ref), - metav1.DeleteOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_DeleteOptions(ref), - metav1.Duration{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Duration(ref), - metav1.FieldSelectorRequirement{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_FieldSelectorRequirement(ref), - metav1.FieldsV1{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_FieldsV1(ref), - metav1.GetOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GetOptions(ref), - metav1.GroupKind{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupKind(ref), - metav1.GroupResource{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupResource(ref), - metav1.GroupVersion{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupVersion(ref), - metav1.GroupVersionForDiscovery{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref), - metav1.GroupVersionKind{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupVersionKind(ref), - metav1.GroupVersionResource{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupVersionResource(ref), - metav1.InternalEvent{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_InternalEvent(ref), - metav1.LabelSelector{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_LabelSelector(ref), - metav1.LabelSelectorRequirement{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref), - metav1.List{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_List(ref), - metav1.ListMeta{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ListMeta(ref), - metav1.ListOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ListOptions(ref), - metav1.ManagedFieldsEntry{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref), - metav1.MicroTime{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_MicroTime(ref), - metav1.ObjectMeta{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ObjectMeta(ref), - metav1.OwnerReference{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_OwnerReference(ref), - metav1.PartialObjectMetadata{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_PartialObjectMetadata(ref), - metav1.PartialObjectMetadataList{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref), - metav1.Patch{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Patch(ref), - metav1.PatchOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_PatchOptions(ref), - metav1.Preconditions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Preconditions(ref), - metav1.RootPaths{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_RootPaths(ref), - metav1.ServerAddressByClientCIDR{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref), - metav1.ShardInfo{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ShardInfo(ref), - metav1.Status{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Status(ref), - metav1.StatusCause{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_StatusCause(ref), - metav1.StatusDetails{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_StatusDetails(ref), - metav1.Table{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Table(ref), - metav1.TableColumnDefinition{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TableColumnDefinition(ref), - metav1.TableOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TableOptions(ref), - metav1.TableRow{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TableRow(ref), - metav1.TableRowCondition{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TableRowCondition(ref), - metav1.Time{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Time(ref), - metav1.Timestamp{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Timestamp(ref), - metav1.TypeMeta{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TypeMeta(ref), - metav1.UpdateOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_UpdateOptions(ref), - metav1.WatchEvent{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_WatchEvent(ref), - runtime.RawExtension{}.OpenAPIModelName(): schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref), - runtime.TypeMeta{}.OpenAPIModelName(): schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref), - runtime.Unknown{}.OpenAPIModelName(): schema_k8sio_apimachinery_pkg_runtime_Unknown(ref), - version.Info{}.OpenAPIModelName(): schema_k8sio_apimachinery_pkg_version_Info(ref), + v1.AmbientEnvironment{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_AmbientEnvironment(ref), + v1.Container{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_Container(ref), + v1.ContainerBuildContext{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerBuildContext(ref), + v1.ContainerBuildSecret{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerBuildSecret(ref), + v1.ContainerExec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerExec(ref), + v1.ContainerExecList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerExecList(ref), + v1.ContainerExecSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerExecSpec(ref), + v1.ContainerExecStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerExecStatus(ref), + v1.ContainerLabel{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerLabel(ref), + v1.ContainerList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerList(ref), + v1.ContainerNetwork{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetwork(ref), + v1.ContainerNetworkConnection{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkConnection(ref), + v1.ContainerNetworkConnectionConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkConnectionConfig(ref), + v1.ContainerNetworkConnectionList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkConnectionList(ref), + v1.ContainerNetworkConnectionSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkConnectionSpec(ref), + v1.ContainerNetworkList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkList(ref), + v1.ContainerNetworkSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkSpec(ref), + v1.ContainerNetworkStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkStatus(ref), + v1.ContainerNetworkTunnelProxy{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkTunnelProxy(ref), + v1.ContainerNetworkTunnelProxyList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkTunnelProxyList(ref), + v1.ContainerNetworkTunnelProxySpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkTunnelProxySpec(ref), + v1.ContainerNetworkTunnelProxyStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerNetworkTunnelProxyStatus(ref), + v1.ContainerPemCertificates{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerPemCertificates(ref), + v1.ContainerPort{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerPort(ref), + v1.ContainerSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerSpec(ref), + v1.ContainerStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerStatus(ref), + v1.ContainerVolume{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerVolume(ref), + v1.ContainerVolumeList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerVolumeList(ref), + v1.ContainerVolumeSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerVolumeSpec(ref), + v1.ContainerVolumeStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ContainerVolumeStatus(ref), + v1.CreateFileSystem{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_CreateFileSystem(ref), + v1.Endpoint{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_Endpoint(ref), + v1.EndpointList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_EndpointList(ref), + v1.EndpointSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_EndpointSpec(ref), + v1.EndpointStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_EndpointStatus(ref), + v1.Executable{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_Executable(ref), + v1.ExecutableList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableList(ref), + v1.ExecutablePemCertificates{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutablePemCertificates(ref), + v1.ExecutableProbe{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableProbe(ref), + v1.ExecutableReplicaSet{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableReplicaSet(ref), + v1.ExecutableReplicaSetList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableReplicaSetList(ref), + v1.ExecutableReplicaSetSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableReplicaSetSpec(ref), + v1.ExecutableReplicaSetStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableReplicaSetStatus(ref), + v1.ExecutableSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableSpec(ref), + v1.ExecutableStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableStatus(ref), + v1.ExecutableTemplate{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ExecutableTemplate(ref), + v1.FileSystemEntry{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_FileSystemEntry(ref), + v1.HealthProbe{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HealthProbe(ref), + v1.HealthProbeResult{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HealthProbeResult(ref), + v1.HealthProbeSchedule{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HealthProbeSchedule(ref), + v1.HttpHeader{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HttpHeader(ref), + v1.HttpProbe{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_HttpProbe(ref), + v1.ImageLayer{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ImageLayer(ref), + v1.LogOptions{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_LogOptions(ref), + v1.LogStreamer{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_LogStreamer(ref), + v1.PemCertificate{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_PemCertificate(ref), + v1.Service{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_Service(ref), + v1.ServiceList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ServiceList(ref), + v1.ServiceSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ServiceSpec(ref), + v1.ServiceStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_ServiceStatus(ref), + v1.TerminalSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_TerminalSpec(ref), + v1.TunnelConfiguration{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_TunnelConfiguration(ref), + v1.TunnelStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_TunnelStatus(ref), + v1.VolumeMount{}.OpenAPIModelName(): schema_microsoft_dcp_api_v1_VolumeMount(ref), + v2.ContainerBuildContext{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_ContainerBuildContext(ref), + v2.ContainerBuildContextArchive{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_ContainerBuildContextArchive(ref), + v2.ContainerBuildSecret{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_ContainerBuildSecret(ref), + v2.ContainerNetworkConnectionConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_ContainerNetworkConnectionConfig(ref), + v2.ContainerPort{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_ContainerPort(ref), + v2.CreateFileSystem{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_CreateFileSystem(ref), + v2.FileSystemEntry{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_FileSystemEntry(ref), + v2.Namespace{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_Namespace(ref), + v2.NamespaceList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_NamespaceList(ref), + v2.NamespaceStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_NamespaceStatus(ref), + v2.PhysicalContainer{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainer(ref), + v2.PhysicalContainerConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerConfig(ref), + v2.PhysicalContainerImage{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImage(ref), + v2.PhysicalContainerImageConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImageConfig(ref), + v2.PhysicalContainerImageList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImageList(ref), + v2.PhysicalContainerImageSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImageSpec(ref), + v2.PhysicalContainerImageStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerImageStatus(ref), + v2.PhysicalContainerList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerList(ref), + v2.PhysicalContainerNetwork{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetwork(ref), + v2.PhysicalContainerNetworkConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkConfig(ref), + v2.PhysicalContainerNetworkConnection{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkConnection(ref), + v2.PhysicalContainerNetworkConnectionList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkConnectionList(ref), + v2.PhysicalContainerNetworkConnectionSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkConnectionSpec(ref), + v2.PhysicalContainerNetworkList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkList(ref), + v2.PhysicalContainerNetworkSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkSpec(ref), + v2.PhysicalContainerNetworkStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerNetworkStatus(ref), + v2.PhysicalContainerPortMapping{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerPortMapping(ref), + v2.PhysicalContainerSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerSpec(ref), + v2.PhysicalContainerStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerStatus(ref), + v2.PhysicalContainerVolume{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolume(ref), + v2.PhysicalContainerVolumeConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolumeConfig(ref), + v2.PhysicalContainerVolumeList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolumeList(ref), + v2.PhysicalContainerVolumeSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolumeSpec(ref), + v2.PhysicalContainerVolumeStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalContainerVolumeStatus(ref), + v2.PhysicalProcess{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcess(ref), + v2.PhysicalProcessConfig{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcessConfig(ref), + v2.PhysicalProcessList{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcessList(ref), + v2.PhysicalProcessSpec{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcessSpec(ref), + v2.PhysicalProcessStatus{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_PhysicalProcessStatus(ref), + v2.VolumeMount{}.OpenAPIModelName(): schema_microsoft_dcp_api_v2_VolumeMount(ref), + commonapi.EnvVar{}.OpenAPIModelName(): schema_microsoft_dcp_pkg_commonapi_EnvVar(ref), + commonapi.Label{}.OpenAPIModelName(): schema_microsoft_dcp_pkg_commonapi_Label(ref), + metav1.APIGroup{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIGroup(ref), + metav1.APIGroupList{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIGroupList(ref), + metav1.APIResource{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIResource(ref), + metav1.APIResourceList{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIResourceList(ref), + metav1.APIVersions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_APIVersions(ref), + metav1.ApplyOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ApplyOptions(ref), + metav1.Condition{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Condition(ref), + metav1.CreateOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_CreateOptions(ref), + metav1.DeleteOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_DeleteOptions(ref), + metav1.Duration{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Duration(ref), + metav1.FieldSelectorRequirement{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_FieldSelectorRequirement(ref), + metav1.FieldsV1{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_FieldsV1(ref), + metav1.GetOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GetOptions(ref), + metav1.GroupKind{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupKind(ref), + metav1.GroupResource{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupResource(ref), + metav1.GroupVersion{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupVersion(ref), + metav1.GroupVersionForDiscovery{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref), + metav1.GroupVersionKind{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupVersionKind(ref), + metav1.GroupVersionResource{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_GroupVersionResource(ref), + metav1.InternalEvent{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_InternalEvent(ref), + metav1.LabelSelector{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_LabelSelector(ref), + metav1.LabelSelectorRequirement{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref), + metav1.List{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_List(ref), + metav1.ListMeta{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ListMeta(ref), + metav1.ListOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ListOptions(ref), + metav1.ManagedFieldsEntry{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref), + metav1.MicroTime{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_MicroTime(ref), + metav1.ObjectMeta{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ObjectMeta(ref), + metav1.OwnerReference{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_OwnerReference(ref), + metav1.PartialObjectMetadata{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_PartialObjectMetadata(ref), + metav1.PartialObjectMetadataList{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref), + metav1.Patch{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Patch(ref), + metav1.PatchOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_PatchOptions(ref), + metav1.Preconditions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Preconditions(ref), + metav1.RootPaths{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_RootPaths(ref), + metav1.ServerAddressByClientCIDR{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref), + metav1.ShardInfo{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_ShardInfo(ref), + metav1.Status{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Status(ref), + metav1.StatusCause{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_StatusCause(ref), + metav1.StatusDetails{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_StatusDetails(ref), + metav1.Table{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Table(ref), + metav1.TableColumnDefinition{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TableColumnDefinition(ref), + metav1.TableOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TableOptions(ref), + metav1.TableRow{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TableRow(ref), + metav1.TableRowCondition{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TableRowCondition(ref), + metav1.Time{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Time(ref), + metav1.Timestamp{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_Timestamp(ref), + metav1.TypeMeta{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_TypeMeta(ref), + metav1.UpdateOptions{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_UpdateOptions(ref), + metav1.WatchEvent{}.OpenAPIModelName(): schema_pkg_apis_meta_v1_WatchEvent(ref), + runtime.RawExtension{}.OpenAPIModelName(): schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref), + runtime.TypeMeta{}.OpenAPIModelName(): schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref), + runtime.Unknown{}.OpenAPIModelName(): schema_k8sio_apimachinery_pkg_runtime_Unknown(ref), + version.Info{}.OpenAPIModelName(): schema_k8sio_apimachinery_pkg_version_Info(ref), } } @@ -4140,8 +4144,20 @@ func schema_microsoft_dcp_api_v2_ContainerBuildContext(ref common.ReferenceCallb Properties: map[string]spec.Schema{ "context": { SchemaProps: spec.SchemaProps{ - Description: "The path to the directory to be used as the root of the build context.", - Default: "", + Description: "The path to the directory to be used as the root of the build context. Exactly one of context or contextArchive must be set.", + Type: []string{"string"}, + Format: "", + }, + }, + "contextArchive": { + SchemaProps: spec.SchemaProps{ + Description: "A tar archive to stream to the image builder as the build context. Exactly one of context or contextArchive must be set.", + Ref: ref(v2.ContainerBuildContextArchive{}.OpenAPIModelName()), + }, + }, + "digest": { + SchemaProps: spec.SchemaProps{ + Description: "An opaque identifier for the logical contents of the build context. PhysicalContainerImage uses this value to determine whether an existing build output can be reused. If omitted, the build context is treated as changed and the output is rebuilt.", Type: []string{"string"}, Format: "", }, @@ -4243,12 +4259,64 @@ func schema_microsoft_dcp_api_v2_ContainerBuildContext(ref common.ReferenceCallb Format: "", }, }, + "baseImages": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "BaseImages identifies image references used by the build. PhysicalContainerImage applies its pull policy to these images and includes their resolved identities when determining whether an existing build output is current.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, }, - Required: []string{"context"}, }, }, Dependencies: []string{ - v2.ContainerBuildSecret{}.OpenAPIModelName(), commonapi.EnvVar{}.OpenAPIModelName(), commonapi.Label{}.OpenAPIModelName()}, + v2.ContainerBuildContextArchive{}.OpenAPIModelName(), v2.ContainerBuildSecret{}.OpenAPIModelName(), commonapi.EnvVar{}.OpenAPIModelName(), commonapi.Label{}.OpenAPIModelName()}, + } +} + +func schema_microsoft_dcp_api_v2_ContainerBuildContextArchive(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ContainerBuildContextArchive describes a tar archive containing an image build context.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "source": { + SchemaProps: spec.SchemaProps{ + Description: "Path to a tar file on the host filesystem. Mutually exclusive with RawContents.", + Type: []string{"string"}, + Format: "", + }, + }, + "sha256": { + SchemaProps: spec.SchemaProps{ + Description: "SHA256 hash of the tar file referenced by Source. Required when Source is set.", + Type: []string{"string"}, + Format: "", + }, + }, + "rawContents": { + SchemaProps: spec.SchemaProps{ + Description: "Base64-encoded tar file contents. Mutually exclusive with Source.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, } } @@ -4299,12 +4367,12 @@ func schema_microsoft_dcp_api_v2_ContainerNetworkConnectionConfig(ref common.Ref return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ - Description: "ContainerNetworkConnectionConfig describes a network to attach to when creating a container.", + Description: "ContainerNetworkConnectionConfig describes a PhysicalContainerNetwork to attach to a container.", Type: []string{"object"}, Properties: map[string]spec.Schema{ "name": { SchemaProps: spec.SchemaProps{ - Description: "Name of the network to connect to.", + Description: "Name of the PhysicalContainerNetwork to connect to in the container's namespace.", Default: "", Type: []string{"string"}, Format: "", @@ -4851,7 +4919,7 @@ func schema_microsoft_dcp_api_v2_PhysicalContainerConfig(ref common.ReferenceCal }, }, SchemaProps: spec.SchemaProps{ - Description: "Networks describes runtime networks to attach the container to when it is created. If omitted, the container runtime chooses the default network.", + Description: "Networks describes PhysicalContainerNetworks to attach the container to. If omitted, the container runtime chooses the default network.", Type: []string{"array"}, Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ @@ -4978,14 +5046,21 @@ func schema_microsoft_dcp_api_v2_PhysicalContainerImageConfig(ref common.Referen }, "pullPolicy": { SchemaProps: spec.SchemaProps{ - Description: "PullPolicy controls source image pulling. If omitted, missing is used. Never is not supported for image builds.", + Description: "PullPolicy controls pulling the source image or declared build base images. Best-effort attempts to pull but uses an existing local image when pulling fails. If omitted, missing is used.", + Type: []string{"string"}, + Format: "", + }, + }, + "buildPolicy": { + SchemaProps: spec.SchemaProps{ + Description: "BuildPolicy controls whether a matching existing build output can be reused. If omitted, ifNeeded is used. Only supported when build is set.", Type: []string{"string"}, Format: "", }, }, "pullRetryLimit": { SchemaProps: spec.SchemaProps{ - Description: "PullRetryLimit is how many times a failed source image pull is retried, with exponential backoff between attempts. Set to zero to fail on the first error. If omitted, a small default number of retries is used to absorb transient registry and network failures.", + Description: "PullRetryLimit is how many times a failed source image pull is retried, with exponential backoff between attempts. Set to zero to fail on the first error. If omitted, a small default number of retries is used to absorb transient registry and network failures. Pulls are deferred while the container runtime is unhealthy, without consuming this limit.", Type: []string{"integer"}, Format: "int32", }, @@ -5316,6 +5391,144 @@ func schema_microsoft_dcp_api_v2_PhysicalContainerNetworkConfig(ref common.Refer } } +func schema_microsoft_dcp_api_v2_PhysicalContainerNetworkConnection(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "PhysicalContainerNetworkConnection represents desired runtime network membership for one physical container.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(metav1.ObjectMeta{}.OpenAPIModelName()), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v2.PhysicalContainerNetworkConnectionSpec{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + Dependencies: []string{ + v2.PhysicalContainerNetworkConnectionSpec{}.OpenAPIModelName(), metav1.ObjectMeta{}.OpenAPIModelName()}, + } +} + +func schema_microsoft_dcp_api_v2_PhysicalContainerNetworkConnectionList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "PhysicalContainerNetworkConnectionList contains a list of PhysicalContainerNetworkConnection instances.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(metav1.ListMeta{}.OpenAPIModelName()), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: ref(v2.PhysicalContainerNetworkConnection{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + }, + Required: []string{"items"}, + }, + }, + Dependencies: []string{ + v2.PhysicalContainerNetworkConnection{}.OpenAPIModelName(), metav1.ListMeta{}.OpenAPIModelName()}, + } +} + +func schema_microsoft_dcp_api_v2_PhysicalContainerNetworkConnectionSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "PhysicalContainerNetworkConnectionSpec identifies the physical container and network that should be connected. Both references resolve within the connection resource's namespace.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "containerRef": { + SchemaProps: spec.SchemaProps{ + Description: "ContainerRef is the name of the PhysicalContainer to connect.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "networkRef": { + SchemaProps: spec.SchemaProps{ + Description: "NetworkRef is the name of the PhysicalContainerNetwork to connect to.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "aliases": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "Aliases contains network-scoped aliases for the container.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + Required: []string{"containerRef", "networkRef"}, + }, + }, + } +} + func schema_microsoft_dcp_api_v2_PhysicalContainerNetworkList(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -5472,6 +5685,25 @@ func schema_microsoft_dcp_api_v2_PhysicalContainerNetworkStatus(ref common.Refer }, }, }, + "containerIDs": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "ContainerIDs are the runtime container IDs currently attached to the network.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, "createdAt": { SchemaProps: spec.SchemaProps{ Description: "CreatedAt is the runtime network creation timestamp.", diff --git a/test/integration/advanced_test_env.go b/test/integration/advanced_test_env.go index dd5a0b5f..f88ef99d 100644 --- a/test/integration/advanced_test_env.go +++ b/test/integration/advanced_test_env.go @@ -9,7 +9,6 @@ import ( "context" "fmt" "math" - "path/filepath" "github.com/go-logr/logr" ctrl "sigs.k8s.io/controller-runtime" @@ -87,6 +86,11 @@ func StartAdvancedTestEnvironmentWithOptions( *AdvancedTestEnvironmentInfo, error, ) { + inclCtrl |= NamespaceController + if inclCtrl&ContainerNetworkTunnelProxyController != 0 { + inclCtrl |= PhysicalContainerImageController | PhysicalContainerController | PhysicalContainerNetworkController + } + sessionFolder, sessionFolderErr := testutil.CreateTestSessionDir() if sessionFolderErr != nil { return nil, nil, fmt.Errorf("failed to create session folder for API server instance: %w", sessionFolderErr) @@ -248,6 +252,7 @@ func StartAdvancedTestEnvironmentWithOptions( mgr.GetAPIReader(), log.WithName("PhysicalContainerReconciler"), serverInfo.ContainerOrchestrator, + pe, ) if err = physicalContainerR.SetupWithManager(mgr, instanceTag+"-PhysicalContainerReconciler"); err != nil { return nil, nil, fmt.Errorf("failed to initialize PhysicalContainer reconciler: %w", err) @@ -341,16 +346,11 @@ func StartAdvancedTestEnvironmentWithOptions( if inclCtrl&ContainerNetworkTunnelProxyController != 0 { tprOpts := controllers.ContainerNetworkTunnelProxyReconcilerConfig{ - Orchestrator: serverInfo.ContainerOrchestrator, ProcessExecutor: pe, MakeTunnelControlClient: dcptunproto.NewTunnelControlClient, MaxTunnelPreparationAttempts: 2, } - if testTempDir != NoSeparateWorkingDir { - tprOpts.MostRecentImageBuildsFilePath = filepath.Join(testTempDir, instanceTag+".imglist") - } - tunnelProxyR := controllers.NewContainerNetworkTunnelProxyReconciler( ctx, mgr.GetClient(), diff --git a/test/integration/container_network_tunnel_proxy_test.go b/test/integration/container_network_tunnel_proxy_test.go index 5b858a09..5ea196be 100644 --- a/test/integration/container_network_tunnel_proxy_test.go +++ b/test/integration/container_network_tunnel_proxy_test.go @@ -27,6 +27,7 @@ import ( ctrl_client "sigs.k8s.io/controller-runtime/pkg/client" apiv1 "github.com/microsoft/dcp/api/v1" + apiv2 "github.com/microsoft/dcp/api/v2" "github.com/microsoft/dcp/controllers" "github.com/microsoft/dcp/internal/apiserver" "github.com/microsoft/dcp/internal/containers" @@ -180,15 +181,34 @@ func TestTunnelProxyDelayedNetworkCreation(t *testing.T) { // Verifies that running ContainerNetworkTunnelProxy has the status updated with client proxy and server proxy information. func TestTunnelProxyRunningStatus(t *testing.T) { - t.Parallel() ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) dcppaths.EnableTestPathProbing() const testName = "test-tunnel-proxy-running-status" + buildContextDir, mkdirErr := os.MkdirTemp("", "dcp-tunnel-") + require.NoError(t, mkdirErr) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(buildContextDir)) + }) + originalDcpTempDir := usvc_io.DcpTempDir + usvc_io.DcpTempDir = func() string { + return buildContextDir + } + t.Cleanup(func() { + usvc_io.DcpTempDir = originalDcpTempDir + }) + includedControllers := ServiceController | NetworkController | ContainerNetworkTunnelProxyController serverInfo, teInfo, startupErr := StartTestEnvironment(ctx, includedControllers, t.Name(), t.TempDir()) require.NoError(t, startupErr, "Failed to start the API server") defer shutdownTestEnvironment(serverInfo, cancel) + testContainerOrchestrator, ok := serverInfo.ContainerOrchestrator.(*ctrl_testutil.TestContainerOrchestrator) + require.True(t, ok) + imagePlan, imagePlanErr := dcptun.PrepareClientProxyImageBuild(ctx) + require.NoError(t, imagePlanErr) + require.NoError(t, os.Remove(imagePlan.BuildContextArchive.Source)) + releaseImageBuild := testContainerOrchestrator.BlockBuildImage(imagePlan.Image) + defer releaseImageBuild() network := apiv1.ContainerNetwork{ ObjectMeta: metav1.ObjectMeta{ @@ -234,17 +254,115 @@ func TestTunnelProxyRunningStatus(t *testing.T) { err = serverInfo.Client.Create(ctx, &tunnelProxy) require.NoError(t, err, "Could not create a ContainerNetworkTunnelProxy object") + secondTunnelProxy := apiv1.ContainerNetworkTunnelProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: testName + "-second", + Namespace: metav1.NamespaceNone, + }, + Spec: apiv1.ContainerNetworkTunnelProxySpec{ + ContainerNetworkName: network.ObjectMeta.Name, + }, + } + err = serverInfo.Client.Create(ctx, &secondTunnelProxy) + require.NoError(t, err, "Could not create a second ContainerNetworkTunnelProxy object") + + t.Log("Waiting for physical containers to be created while the shared image build is blocked...") + waitContainersErr := wait.PollUntilContextCancel(ctx, waitPollInterval, pollImmediately, func(ctx context.Context) (bool, error) { + physicalContainers := apiv2.PhysicalContainerList{} + listErr := serverInfo.Client.List(ctx, &physicalContainers, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName)) + return len(physicalContainers.Items) == 2, listErr + }) + require.NoError(t, waitContainersErr) + waitingTunnelProxy := waitObjectAssumesStateEx(t, ctx, serverInfo.Client, tunnelProxy.NamespacedName(), func(tp *apiv1.ContainerNetworkTunnelProxy) (bool, error) { + return tp.Status.State == apiv1.ContainerNetworkTunnelProxyStateBuildingImage && tp.Status.Message != "", nil + }) + require.Contains(t, waitingTunnelProxy.Status.Message, "Waiting for") + require.Equal(t, 1, testContainerOrchestrator.BuildImageCallCount(imagePlan.Image)) + releaseImageBuild() + t.Log("Waiting for ContainerNetworkTunnelProxy to transition to Running state...") updatedTunnelProxy := waitObjectAssumesStateEx(t, ctx, serverInfo.Client, tunnelProxy.NamespacedName(), func(tp *apiv1.ContainerNetworkTunnelProxy) (bool, error) { return tp.Status.State == apiv1.ContainerNetworkTunnelProxyStateRunning, nil }) + waitObjectAssumesStateEx(t, ctx, serverInfo.Client, secondTunnelProxy.NamespacedName(), func(tp *apiv1.ContainerNetworkTunnelProxy) (bool, error) { + return tp.Status.State == apiv1.ContainerNetworkTunnelProxyStateRunning, nil + }) t.Log("Verifying client proxy status...") + require.Empty(t, updatedTunnelProxy.Status.Message, "Running tunnel proxy should not retain a startup wait message") require.NotEmpty(t, updatedTunnelProxy.Status.ClientProxyContainerImage, "Tunnel proxy should publish the image for the client proxy container") require.NotEmpty(t, updatedTunnelProxy.Status.ClientProxyContainerID, "Tunnel proxy should have a client proxy container ID") require.True(t, networking.IsValidPort(int(updatedTunnelProxy.Status.ClientProxyControlPort)), "Tunnel proxy should have a valid client proxy control port") require.True(t, networking.IsValidPort(int(updatedTunnelProxy.Status.ClientProxyDataPort)), "Tunnel proxy should have a valid client proxy data port") + t.Log("Verifying V2 physical resources represent the client proxy...") + physicalImages := apiv2.PhysicalContainerImageList{} + listImagesErr := serverInfo.Client.List(ctx, &physicalImages, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName)) + require.NoError(t, listImagesErr) + require.Len(t, physicalImages.Items, 1) + require.Equal(t, controllers.V1TunnelProxyPhysicalContainerImageName, physicalImages.Items[0].Name) + require.Equal(t, updatedTunnelProxy.Status.ClientProxyContainerImage, physicalImages.Items[0].Spec.Image.Image) + require.Equal(t, apiv2.PullPolicyBestEffort, physicalImages.Items[0].Spec.Image.PullPolicy) + require.Equal(t, apiv2.BuildPolicyIfNeeded, physicalImages.Items[0].Spec.Image.BuildPolicy) + require.NotNil(t, physicalImages.Items[0].Spec.Image.Build) + require.Equal(t, []string{dcptun.DefaultBaseImage}, physicalImages.Items[0].Spec.Image.Build.BaseImages) + require.NotEmpty(t, physicalImages.Items[0].Spec.Image.Build.Digest) + require.NotNil(t, physicalImages.Items[0].Spec.Image.Build.ContextArchive) + require.NotEmpty(t, physicalImages.Items[0].Spec.Image.Build.ContextArchive.Source) + require.NotEmpty(t, physicalImages.Items[0].Spec.Image.Build.ContextArchive.SHA256) + require.Empty(t, physicalImages.Items[0].Spec.Image.Build.ContextArchive.RawContents) + require.FileExists(t, physicalImages.Items[0].Spec.Image.Build.ContextArchive.Source) + buildContextFiles, globErr := filepath.Glob(filepath.Join(buildContextDir, "dcptun-build-context-*.tar")) + require.NoError(t, globErr) + require.Equal(t, []string{physicalImages.Items[0].Spec.Image.Build.ContextArchive.Source}, buildContextFiles) + t.Cleanup(func() { + require.NoError(t, os.Remove(physicalImages.Items[0].Spec.Image.Build.ContextArchive.Source)) + }) + require.Equal(t, apiv2.PhysicalContainerImagePhaseReady, physicalImages.Items[0].Status.Phase) + require.Equal(t, 1, testContainerOrchestrator.BuildImageCallCount(updatedTunnelProxy.Status.ClientProxyContainerImage)) + + physicalContainers := apiv2.PhysicalContainerList{} + listContainersErr := serverInfo.Client.List(ctx, &physicalContainers, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName)) + require.NoError(t, listContainersErr) + require.Len(t, physicalContainers.Items, 2) + foundClientPhysicalContainer := false + clientPhysicalContainerName := "" + for _, physicalContainer := range physicalContainers.Items { + require.Equal(t, controllers.V1TunnelProxyPhysicalContainerImageName, physicalContainer.Spec.Container.ImageRef) + require.Equal(t, apiv2.PhysicalContainerPhaseRunning, physicalContainer.Status.Phase) + if physicalContainer.Status.ContainerID == updatedTunnelProxy.Status.ClientProxyContainerID { + foundClientPhysicalContainer = true + clientPhysicalContainerName = physicalContainer.Name + } + } + require.True(t, foundClientPhysicalContainer) + + physicalNetworks := apiv2.PhysicalContainerNetworkList{} + listNetworksErr := serverInfo.Client.List(ctx, &physicalNetworks, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName)) + require.NoError(t, listNetworksErr) + require.Len(t, physicalNetworks.Items, 1) + for _, physicalNetwork := range physicalNetworks.Items { + require.Equal(t, updatedNetwork.Status.ID, physicalNetwork.Spec.NetworkID) + require.Equal(t, apiv2.PhysicalContainerNetworkPhaseReady, physicalNetwork.Status.Phase) + require.Equal(t, updatedNetwork.Status.ID, physicalNetwork.Status.NetworkID) + require.Equal(t, updatedNetwork.Name, physicalNetwork.Annotations[controllers.V1ContainerNetworkNameAnnotation]) + require.Len(t, physicalNetwork.Status.ContainerIDs, 2) + require.True(t, std_slices.Contains(physicalNetwork.Status.ContainerIDs, updatedTunnelProxy.Status.ClientProxyContainerID)) + } + + physicalConnections := apiv2.PhysicalContainerNetworkConnectionList{} + listConnectionsErr := serverInfo.Client.List(ctx, &physicalConnections, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName)) + require.NoError(t, listConnectionsErr) + require.Len(t, physicalConnections.Items, 2) + foundClientPhysicalConnection := false + for _, physicalConnection := range physicalConnections.Items { + if physicalConnection.Spec.ContainerRef == clientPhysicalContainerName { + foundClientPhysicalConnection = true + require.Equal(t, aliases, physicalConnection.Spec.Aliases) + } + } + require.True(t, foundClientPhysicalConnection) + t.Log("Verifying client proxy container exists...") inspectedContainers, inspectErr := serverInfo.ContainerOrchestrator.InspectContainers(ctx, containers.InspectContainersOptions{ Containers: []string{updatedTunnelProxy.Status.ClientProxyContainerID}, @@ -252,7 +370,7 @@ func TestTunnelProxyRunningStatus(t *testing.T) { require.NoError(t, inspectErr, "Should be able to inspect client proxy container") require.Len(t, inspectedContainers, 1, "Should find exactly one container") clientContainer := inspectedContainers[0] - require.Equal(t, updatedTunnelProxy.Status.ClientProxyContainerImage, clientContainer.Image, "Container should have the expected image") + require.Equal(t, physicalImages.Items[0].Status.ImageID, clientContainer.Image, "Container should use the physical image ID") require.Equal(t, containers.ContainerStatusRunning, clientContainer.Status, "Container should be running") require.Len(t, clientContainer.Networks, 1, "Client proxy container should only be attached to the target network") require.Equal(t, updatedNetwork.Status.NetworkName, clientContainer.Networks[0].Name, "Client proxy container should be attached to the target network during creation") @@ -283,6 +401,12 @@ func TestTunnelProxyRunningStatus(t *testing.T) { require.Equal(t, fmt.Sprintf("%d", updatedTunnelProxy.Status.ClientProxyControlPort), pe.Cmd.Args[3], "Third argument should be client control port") require.Equal(t, networking.IPv4LocalhostDefaultAddress, pe.Cmd.Args[4], "Fourth argument should be client data address") require.Equal(t, fmt.Sprintf("%d", updatedTunnelProxy.Status.ClientProxyDataPort), pe.Cmd.Args[5], "Fifth argument should be client data port") + + sharedPhysicalImages := apiv2.PhysicalContainerImageList{} + require.NoError(t, serverInfo.Client.List(ctx, &sharedPhysicalImages, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName))) + require.Len(t, sharedPhysicalImages.Items, 1) + require.Equal(t, controllers.V1TunnelProxyPhysicalContainerImageName, sharedPhysicalImages.Items[0].Name) + require.Equal(t, 1, testContainerOrchestrator.BuildImageCallCount(updatedTunnelProxy.Status.ClientProxyContainerImage)) } // Verifies that ContainerNetworkTunnelProxy proxy pair cleanup works correctly during object deletion. @@ -293,8 +417,8 @@ func TestTunnelProxyCleanup(t *testing.T) { dcppaths.EnableTestPathProbing() const testName = "test-tunnel-proxy-cleanup" - controllers := ServiceController | NetworkController | ContainerNetworkTunnelProxyController - serverInfo, teInfo, startupErr := StartTestEnvironment(ctx, controllers, t.Name(), t.TempDir()) + includedControllers := ServiceController | NetworkController | ContainerNetworkTunnelProxyController + serverInfo, teInfo, startupErr := StartTestEnvironment(ctx, includedControllers, t.Name(), t.TempDir()) require.NoError(t, startupErr, "Failed to start the API server") defer shutdownTestEnvironment(serverInfo, cancel) @@ -356,6 +480,38 @@ func TestTunnelProxyCleanup(t *testing.T) { require.Len(t, containerInfoList, 1, "Client proxy container should exist") require.Equal(t, clientContainerID, containerInfoList[0].Id, "Container ID should match") + physicalContainers := apiv2.PhysicalContainerList{} + listContainersErr := serverInfo.Client.List(ctx, &physicalContainers, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName)) + require.NoError(t, listContainersErr) + require.Len(t, physicalContainers.Items, 1) + physicalContainer := physicalContainers.Items[0] + + physicalNetworks := apiv2.PhysicalContainerNetworkList{} + listNetworksErr := serverInfo.Client.List(ctx, &physicalNetworks, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName)) + require.NoError(t, listNetworksErr) + require.Len(t, physicalNetworks.Items, 1) + physicalNetwork := physicalNetworks.Items[0] + + physicalConnections := apiv2.PhysicalContainerNetworkConnectionList{} + listConnectionsErr := serverInfo.Client.List(ctx, &physicalConnections, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName)) + require.NoError(t, listConnectionsErr) + require.Len(t, physicalConnections.Items, 1) + physicalConnection := physicalConnections.Items[0] + + physicalImages := apiv2.PhysicalContainerImageList{} + listImagesErr := serverInfo.Client.List(ctx, &physicalImages, ctrl_client.InNamespace(controllers.V1PhysicalResourcesNamespaceName)) + require.NoError(t, listImagesErr) + require.Len(t, physicalImages.Items, 1) + physicalImage := physicalImages.Items[0] + require.NotNil(t, physicalImage.Spec.Image.Build) + require.NotNil(t, physicalImage.Spec.Image.Build.ContextArchive) + require.NotEmpty(t, physicalImage.Spec.Image.Build.ContextArchive.Source) + require.Empty(t, physicalImage.Spec.Image.Build.ContextArchive.RawContents) + require.FileExists(t, physicalImage.Spec.Image.Build.ContextArchive.Source) + t.Cleanup(func() { + require.NoError(t, os.Remove(physicalImage.Spec.Image.Build.ContextArchive.Source)) + }) + t.Logf("Deleting ContainerNetworkTunnelProxy object '%s'", tunnelProxy.ObjectMeta.Name) err = retryOnConflictEx(ctx, serverInfo.Client, tunnelProxy.NamespacedName(), func(ctx context.Context, tp *apiv1.ContainerNetworkTunnelProxy) error { return serverInfo.Client.Delete(ctx, tp) @@ -366,6 +522,15 @@ func TestTunnelProxyCleanup(t *testing.T) { ctrl_testutil.WaitObjectDeleted(t, ctx, serverInfo.Client, &tunnelProxy) t.Log("Verifying proxy resources are cleaned up...") + ctrl_testutil.WaitObjectDeleted(t, ctx, serverInfo.Client, &physicalContainer) + ctrl_testutil.WaitObjectDeleted(t, ctx, serverInfo.Client, &physicalConnection) + retainedPhysicalNetwork := apiv2.PhysicalContainerNetwork{} + require.NoError(t, serverInfo.Client.Get(ctx, physicalNetwork.NamespacedName(), &retainedPhysicalNetwork)) + require.Equal(t, apiv2.PhysicalContainerNetworkPhaseReady, retainedPhysicalNetwork.Status.Phase) + retainedPhysicalImage := apiv2.PhysicalContainerImage{} + require.NoError(t, serverInfo.Client.Get(ctx, physicalImage.NamespacedName(), &retainedPhysicalImage)) + require.Equal(t, apiv2.PhysicalContainerImagePhaseReady, retainedPhysicalImage.Status.Phase) + require.FileExists(t, retainedPhysicalImage.Spec.Image.Build.ContextArchive.Source) // Verify the client container has been removed _, inspectErrAfter := orchestrator.InspectContainers(ctx, containers.InspectContainersOptions{ @@ -1064,13 +1229,74 @@ func TestTunnelProxyServerUnexpectedExit(t *testing.T) { _ = waitAllTunnelsInState(t, ctx, serverInfo.Client, tunnelProxy.NamespacedName(), len(tunnelProxy.Spec.Tunnels), apiv1.TunnelStateFailed) } -// Verifies that ContainerNetworkTunnelProxy transitions to Failed state when client proxy container unexpectedly stops running. -func TestTunnelProxyClientUnexpectedExit(t *testing.T) { +func TestTunnelProxyClientExited(t *testing.T) { + t.Parallel() + testTunnelProxyClientFailure(t, "test-tunnel-proxy-client-unexpected-exit", "Runtime container has exited", func( + ctx context.Context, + orchestrator *ctrl_testutil.TestContainerOrchestrator, + containerID string, + ) error { + return orchestrator.SimulateContainerExit(ctx, containerID, 5) + }, nil) +} + +func TestTunnelProxyClientDestroyed(t *testing.T) { + t.Parallel() + testTunnelProxyClientFailure(t, "test-tunnel-proxy-client-destroyed", "Runtime container was not found", func( + ctx context.Context, + orchestrator *ctrl_testutil.TestContainerOrchestrator, + containerID string, + ) error { + removedContainers, removeErr := orchestrator.RemoveContainers(ctx, containers.RemoveContainersOptions{ + Containers: []string{containerID}, + Force: true, + }) + if removeErr != nil { + return removeErr + } + if !std_slices.Equal(removedContainers, []string{containerID}) { + return fmt.Errorf("unexpected removed containers: %v", removedContainers) + } + return nil + }, nil) +} + +func TestTunnelProxyClientPaused(t *testing.T) { t.Parallel() + testTunnelProxyClientFailure(t, "test-tunnel-proxy-client-paused", "Runtime container is paused", func( + ctx context.Context, + orchestrator *ctrl_testutil.TestContainerOrchestrator, + containerID string, + ) error { + return orchestrator.SimulateContainerStatus(ctx, containerID, containers.ContainerStatusPaused) + }, func(ctx context.Context, orchestrator *ctrl_testutil.TestContainerOrchestrator, containerID string) error { + return orchestrator.SimulateContainerStatus(ctx, containerID, containers.ContainerStatusRunning) + }) +} + +func TestTunnelProxyClientPortMappingsMissing(t *testing.T) { + t.Parallel() + testTunnelProxyClientFailure(t, "test-tunnel-proxy-client-port-mappings-missing", "no host port mapping exists", func( + ctx context.Context, + orchestrator *ctrl_testutil.TestContainerOrchestrator, + containerID string, + ) error { + return orchestrator.SimulateContainerPortMappings(ctx, containerID, nil) + }, nil) +} + +// Verifies that ContainerNetworkTunnelProxy transitions to Failed state when its runtime client proxy container becomes unusable. +func testTunnelProxyClientFailure( + t *testing.T, + testName string, + expectedMessage string, + terminateContainer func(context.Context, *ctrl_testutil.TestContainerOrchestrator, string) error, + afterFailure func(context.Context, *ctrl_testutil.TestContainerOrchestrator, string) error, +) { + t.Helper() ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) defer cancel() dcppaths.EnableTestPathProbing() - const testName = "test-tunnel-proxy-client-unexpected-exit" includedControllers := ServiceController | NetworkController | ContainerNetworkTunnelProxyController serverInfo, teInfo, startupErr := StartTestEnvironment(ctx, includedControllers, t.Name(), t.TempDir()) @@ -1144,16 +1370,19 @@ func TestTunnelProxyClientUnexpectedExit(t *testing.T) { t.Log("Verifying all tunnels are in NotReady state...") _ = waitAllTunnelsInState(t, ctx, serverInfo.Client, tunnelProxy.NamespacedName(), len(tunnelProxy.Spec.Tunnels), apiv1.TunnelStateNotReady) - t.Logf("Simulating client proxy container exit with code 5 (container ID: %s)...", clientContainerID) - tco, isTCO := serverInfo.ContainerOrchestrator.(*ctrl_testutil.TestContainerOrchestrator) - require.True(t, isTCO, "Container orchestrator should be a TestContainerOrchestrator") - simulateErr := tco.SimulateContainerExit(ctx, clientContainerID, 5) - require.NoError(t, simulateErr, "Should be able to simulate container exit") + testContainerOrchestrator, isTestContainerOrchestrator := serverInfo.ContainerOrchestrator.(*ctrl_testutil.TestContainerOrchestrator) + require.True(t, isTestContainerOrchestrator) + t.Logf("Terminating client proxy runtime container (container ID: %s)...", clientContainerID) + require.NoError(t, terminateContainer(ctx, testContainerOrchestrator, clientContainerID)) t.Log("Waiting for ContainerNetworkTunnelProxy to transition to Failed state...") - _ = waitObjectAssumesStateEx(t, ctx, serverInfo.Client, tunnelProxy.NamespacedName(), func(tp *apiv1.ContainerNetworkTunnelProxy) (bool, error) { + failedTunnelProxy := waitObjectAssumesStateEx(t, ctx, serverInfo.Client, tunnelProxy.NamespacedName(), func(tp *apiv1.ContainerNetworkTunnelProxy) (bool, error) { return tp.Status.State == apiv1.ContainerNetworkTunnelProxyStateFailed, nil }) + require.Contains(t, failedTunnelProxy.Status.Message, expectedMessage) + if afterFailure != nil { + require.NoError(t, afterFailure(ctx, testContainerOrchestrator, clientContainerID)) + } t.Log("Verifying server proxy process has been stopped...") waitErr := wait.PollUntilContextCancel(ctx, waitPollInterval, true /* poll immediately */, func(_ context.Context) (bool, error) { diff --git a/test/integration/standard_test_env.go b/test/integration/standard_test_env.go index 96f4cacd..7e78fb82 100644 --- a/test/integration/standard_test_env.go +++ b/test/integration/standard_test_env.go @@ -9,7 +9,6 @@ import ( "context" "fmt" "math" - "path/filepath" "time" "github.com/go-logr/logr" @@ -75,6 +74,11 @@ func StartTestEnvironmentWithOptions( *TestEnvironmentInfo, error, ) { + inclCtrl |= NamespaceController + if inclCtrl&ContainerNetworkTunnelProxyController != 0 { + inclCtrl |= PhysicalContainerImageController | PhysicalContainerController | PhysicalContainerNetworkController + } + sessionFolder, sessionFolderErr := testutil.CreateTestSessionDir() if sessionFolderErr != nil { return nil, nil, fmt.Errorf("failed to create session folder for API server instance: %w", sessionFolderErr) @@ -273,6 +277,7 @@ func StartTestEnvironmentWithOptions( mgr.GetAPIReader(), log.WithName("PhysicalContainerReconciler"), serverInfo.ContainerOrchestrator, + pex, ) if err = physicalContainerR.SetupWithManager(mgr, instanceTag+"-PhysicalContainerReconciler"); err != nil { return nil, nil, fmt.Errorf("failed to initialize PhysicalContainer reconciler: %w", err) @@ -371,15 +376,9 @@ func StartTestEnvironmentWithOptions( if inclCtrl&ContainerNetworkTunnelProxyController != 0 { tcc = ctrl_testutil.NewTestTunnelControlClient() tprOpts := controllers.ContainerNetworkTunnelProxyReconcilerConfig{ - Orchestrator: serverInfo.ContainerOrchestrator, - ProcessExecutor: pex, - MakeTunnelControlClient: func(_ grpc.ClientConnInterface) dcptunproto.TunnelControlClient { return tcc }, - MaxTunnelPreparationAttempts: 2, - ContainerStartupTimeoutOverride: 2 * time.Second, - } - - if testTempDir != NoSeparateWorkingDir { - tprOpts.MostRecentImageBuildsFilePath = filepath.Join(testTempDir, instanceTag+".imglist") + ProcessExecutor: pex, + MakeTunnelControlClient: func(_ grpc.ClientConnInterface) dcptunproto.TunnelControlClient { return tcc }, + MaxTunnelPreparationAttempts: 2, } tunnelProxyR := controllers.NewContainerNetworkTunnelProxyReconciler( diff --git a/test/integration/v2_physical_container_controller_test.go b/test/integration/v2_physical_container_controller_test.go index ca3e22bf..d53b6f57 100644 --- a/test/integration/v2_physical_container_controller_test.go +++ b/test/integration/v2_physical_container_controller_test.go @@ -8,6 +8,7 @@ package integration_test import ( "context" "errors" + std_slices "slices" "strings" "testing" "time" @@ -16,15 +17,45 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" + ctrl_client "sigs.k8s.io/controller-runtime/pkg/client" apiv2 "github.com/microsoft/dcp/api/v2" "github.com/microsoft/dcp/controllers" "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/internal/statestore" + internal_testutil "github.com/microsoft/dcp/internal/testutil" ctrl_testutil "github.com/microsoft/dcp/internal/testutil/ctrlutil" "github.com/microsoft/dcp/pkg/commonapi" "github.com/microsoft/dcp/pkg/testutil" ) +type invalidPortMappingContainerOrchestrator struct { + containers.ContainerOrchestrator + containerName string +} + +func (orchestrator *invalidPortMappingContainerOrchestrator) InspectContainers( + ctx context.Context, + options containers.InspectContainersOptions, +) ([]containers.InspectedContainer, error) { + inspectedContainers, inspectErr := orchestrator.ContainerOrchestrator.InspectContainers(ctx, options) + if inspectErr != nil { + return nil, inspectErr + } + + for i := range inspectedContainers { + if inspectedContainers[i].Name != orchestrator.containerName { + continue + } + if inspectedContainers[i].Ports == nil { + inspectedContainers[i].Ports = make(containers.InspectedContainerPortMapping) + } + inspectedContainers[i].Ports["invalid-port"] = nil + } + + return inspectedContainers, nil +} + func TestV2PhysicalContainerControllerCreatesContainer(t *testing.T) { t.Parallel() ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) @@ -77,6 +108,7 @@ func TestV2PhysicalContainerControllerCreatesContainer(t *testing.T) { require.NotEqual(t, "caller-value", inspectedContainers[0].Labels[controllers.CreatorProcessIdLabel]) require.NotEmpty(t, inspectedContainers[0].Labels[controllers.CreatorProcessStartTimeLabel]) require.NotEqual(t, "caller-value", inspectedContainers[0].Labels[controllers.CreatorProcessStartTimeLabel]) + require.Len(t, physicalContainerMonitorProcesses(updatedContainer.Status.ContainerID), 1) } func TestV2PhysicalContainerControllerReconcilesWhenNamespaceBecomesActive(t *testing.T) { @@ -166,9 +198,18 @@ func TestV2PhysicalContainerControllerCreatesContainerWithNetworks(t *testing.T) namespace := createActiveV2Namespace(t, ctx, "v2-pctr-networks") image := createReadyV2PhysicalContainerImage(t, ctx, namespace.Name, "networked-image", "networked-image") networkName := "v2-pctr-networked-runtime" - _, networkErr := containerOrchestrator.CreateNetwork(ctx, containers.CreateNetworkOptions{Name: networkName}) + runtimeNetwork, networkErr := containerOrchestrator.CreateNetwork(ctx, containers.CreateNetworkOptions{Name: networkName}) require.NoError(t, networkErr) removeRuntimeNetworkOnCleanup(t, networkName) + network := &apiv2.PhysicalContainerNetwork{ + ObjectMeta: metav1.ObjectMeta{ + Name: "networked-network", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerNetworkSpec{NetworkID: runtimeNetwork}, + } + require.NoError(t, client.Create(ctx, network)) + waitPhysicalContainerNetworkPhase(t, ctx, network.NamespacedName(), apiv2.PhysicalContainerNetworkPhaseReady) container := &apiv2.PhysicalContainer{ ObjectMeta: metav1.ObjectMeta{ @@ -179,7 +220,7 @@ func TestV2PhysicalContainerControllerCreatesContainerWithNetworks(t *testing.T) ContainerName: "v2-pctr-networked-container", Networks: []apiv2.ContainerNetworkConnectionConfig{ { - Name: networkName, + Name: network.Name, Aliases: []string{"api", "service"}, }, }}, @@ -198,6 +239,67 @@ func TestV2PhysicalContainerControllerCreatesContainerWithNetworks(t *testing.T) require.Len(t, inspectedContainers[0].Networks, 1) require.Equal(t, networkName, inspectedContainers[0].Networks[0].Name) require.ElementsMatch(t, []string{"api", "service"}, inspectedContainers[0].Networks[0].Aliases) + + connections := apiv2.PhysicalContainerNetworkConnectionList{} + require.NoError(t, client.List(ctx, &connections, ctrl_client.InNamespace(namespace.Name))) + require.Len(t, connections.Items, 1) + require.Equal(t, container.Name, connections.Items[0].Spec.ContainerRef) + require.Equal(t, network.Name, connections.Items[0].Spec.NetworkRef) + require.ElementsMatch(t, []string{"api", "service"}, connections.Items[0].Spec.Aliases) + require.True(t, metav1.IsControlledBy(&connections.Items[0], container)) +} + +func TestV2PhysicalContainerControllerWaitsForNetwork(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + namespace := createActiveV2Namespace(t, ctx, "v2-pctr-waits-network") + image := createReadyV2PhysicalContainerImage(t, ctx, namespace.Name, "waits-network-image", "waits-network-image") + networkName := "waited-network" + containerName := "v2-pctr-waits-network-container" + container := &apiv2.PhysicalContainer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "waits-network-container", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerSpec{ + Container: &apiv2.PhysicalContainerConfig{ + ImageRef: image.Name, + ContainerName: containerName, + Networks: []apiv2.ContainerNetworkConnectionConfig{ + {Name: networkName}, + }, + }, + }, + } + require.NoError(t, client.Create(ctx, container)) + + pendingContainer := waitObjectAssumesState(t, ctx, container.NamespacedName(), func(currentContainer *apiv2.PhysicalContainer) (bool, error) { + readyCondition := apimeta.FindStatusCondition(currentContainer.Status.Conditions, string(apiv2.ConditionReady)) + return currentContainer.Status.Phase == apiv2.PhysicalContainerPhasePending && + readyCondition != nil && + apiv2.ConditionReason(readyCondition.Reason) == apiv2.PhysicalContainerReasonNetworkNotFound, nil + }) + requireReadyCondition(t, pendingContainer.Status.Conditions, metav1.ConditionFalse, apiv2.PhysicalContainerReasonNetworkNotFound) + require.Equal(t, 0, containerOrchestrator.CreateContainerCallCount(containerName)) + + runtimeNetworkName := "v2-pctr-waits-network-runtime" + removeRuntimeNetworkOnCleanup(t, runtimeNetworkName) + network := &apiv2.PhysicalContainerNetwork{ + ObjectMeta: metav1.ObjectMeta{ + Name: networkName, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerNetworkSpec{ + Network: &apiv2.PhysicalContainerNetworkConfig{NetworkName: runtimeNetworkName}, + }, + } + require.NoError(t, client.Create(ctx, network)) + + updatedContainer := waitPhysicalContainerPhase(t, ctx, container.NamespacedName(), apiv2.PhysicalContainerPhaseRunning) + removeRuntimeContainerOnCleanup(t, updatedContainer.Status.ContainerID) + require.Equal(t, 1, containerOrchestrator.CreateContainerCallCount(containerName)) } func TestV2PhysicalContainerControllerReportsPortMappings(t *testing.T) { @@ -271,6 +373,81 @@ func TestV2PhysicalContainerControllerReportsPortMappings(t *testing.T) { require.Equal(t, int32(19102), fifthMapping.HostPort) } +func TestV2PhysicalContainerControllerPreservesRuntimePhaseOnPortMappingFailure(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + const runtimeContainerName = "v2-pctr-invalid-port-runtime" + var testContainerOrchestrator *ctrl_testutil.TestContainerOrchestrator + serverInfo, _, startupErr := StartTestEnvironmentWithOptions( + ctx, + PhysicalContainerController, + t.Name(), + t.TempDir(), + TestEnvironmentOptions{ + DecorateContainerOrchestrator: func( + orchestrator containers.ContainerOrchestrator, + _ *statestore.Store, + ) containers.ContainerOrchestrator { + var isTestContainerOrchestrator bool + testContainerOrchestrator, isTestContainerOrchestrator = orchestrator.(*ctrl_testutil.TestContainerOrchestrator) + require.True(t, isTestContainerOrchestrator) + return &invalidPortMappingContainerOrchestrator{ + ContainerOrchestrator: orchestrator, + containerName: runtimeContainerName, + } + }, + }, + ) + require.NoError(t, startupErr) + defer shutdownTestEnvironment(serverInfo, cancel) + + runtimeContainerID, runErr := testContainerOrchestrator.RunContainer(ctx, containers.RunContainerOptions{ + CreateContainerOptions: containers.CreateContainerOptions{ + Name: runtimeContainerName, + Image: "existing-image", + }, + }) + require.NoError(t, runErr) + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), defaultIntegrationTestTimeout) + defer cleanupCancel() + _, _ = testContainerOrchestrator.RemoveContainersWithoutEvents(cleanupCtx, containers.RemoveContainersOptions{ + Containers: []string{runtimeContainerID}, + Force: true, + }) + }() + + namespace := &apiv2.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "v2-pctr-invalid-port"}} + require.NoError(t, serverInfo.Client.Create(ctx, namespace)) + waitObjectAssumesStateEx(t, ctx, serverInfo.Client, namespace.NamespacedName(), func(current *apiv2.Namespace) (bool, error) { + return current.Status.Phase == apiv2.NamespacePhaseActive, nil + }) + + container := &apiv2.PhysicalContainer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-port-container", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerSpec{ContainerID: runtimeContainerID}, + } + require.NoError(t, serverInfo.Client.Create(ctx, container)) + + updatedContainer := waitObjectAssumesStateEx(t, ctx, serverInfo.Client, container.NamespacedName(), func(current *apiv2.PhysicalContainer) (bool, error) { + readyCondition := apimeta.FindStatusCondition(current.Status.Conditions, string(apiv2.ConditionReady)) + return current.Status.Phase == apiv2.PhysicalContainerPhaseRunning && + readyCondition != nil && + readyCondition.Reason == string(apiv2.PhysicalContainerReasonPortMappingResolutionFailed), nil + }) + requireReadyCondition( + t, + updatedContainer.Status.Conditions, + metav1.ConditionFalse, + apiv2.PhysicalContainerReasonPortMappingResolutionFailed, + ) +} + func TestV2PhysicalContainerControllerCopiesCreateFilesBeforeStart(t *testing.T) { t.Parallel() ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) @@ -607,6 +784,7 @@ func TestV2PhysicalContainerControllerTracksExistingContainer(t *testing.T) { require.NoError(t, inspectErr) require.Len(t, inspectedContainers, 1) require.NotContains(t, inspectedContainers[0].Labels, controllers.CreatorProcessIdLabel) + require.Empty(t, physicalContainerMonitorProcesses(existingContainerID)) } func TestV2PhysicalContainerControllerRejectsDuplicateRuntimeContainerOwnership(t *testing.T) { @@ -812,6 +990,13 @@ func TestV2PhysicalContainerControllerPreservesCreatedContainerOnDeletion(t *tes require.NoError(t, inspectErr) require.Len(t, inspectedContainers, 1) require.Equal(t, "true", inspectedContainers[0].Labels[controllers.PersistentLabel]) + require.Empty(t, physicalContainerMonitorProcesses(containerID)) +} + +func physicalContainerMonitorProcesses(containerID string) []*internal_testutil.ProcessExecution { + return testProcessExecutor.FindAll([]string{"dcp", "monitor-container"}, "", func(processExecution *internal_testutil.ProcessExecution) bool { + return std_slices.Contains(processExecution.Cmd.Args, containerID) + }) } func TestV2PhysicalContainerControllerPreservesExistingContainerOnDeletion(t *testing.T) { diff --git a/test/integration/v2_physical_container_image_controller_test.go b/test/integration/v2_physical_container_image_controller_test.go index 44ddee87..c268c4e0 100644 --- a/test/integration/v2_physical_container_image_controller_test.go +++ b/test/integration/v2_physical_container_image_controller_test.go @@ -7,7 +7,9 @@ package integration_test import ( "context" + "encoding/base64" "errors" + "path/filepath" "testing" "time" @@ -19,11 +21,90 @@ import ( apiv2 "github.com/microsoft/dcp/api/v2" "github.com/microsoft/dcp/controllers" "github.com/microsoft/dcp/internal/containers" + "github.com/microsoft/dcp/internal/statestore" ctrl_testutil "github.com/microsoft/dcp/internal/testutil/ctrlutil" "github.com/microsoft/dcp/pkg/commonapi" + usvc_io "github.com/microsoft/dcp/pkg/io" + "github.com/microsoft/dcp/pkg/osutil" "github.com/microsoft/dcp/pkg/testutil" ) +type recordingBuildImageOrchestrator struct { + containers.ContainerOrchestrator + buildOptions chan containers.BuildImageOptions +} + +func (r *recordingBuildImageOrchestrator) BuildImage(ctx context.Context, options containers.BuildImageOptions) error { + select { + case r.buildOptions <- options: + default: + } + return r.ContainerOrchestrator.BuildImage(ctx, options) +} + +func TestV2PhysicalContainerImageControllerBuildsRawArchiveContext(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + + var recordingOrchestrator *recordingBuildImageOrchestrator + serverInfo, _, startErr := StartTestEnvironmentWithOptions( + ctx, + NamespaceController|PhysicalContainerImageController, + t.Name(), + t.TempDir(), + TestEnvironmentOptions{ + DecorateContainerOrchestrator: func( + orchestrator containers.ContainerOrchestrator, + _ *statestore.Store, + ) containers.ContainerOrchestrator { + recordingOrchestrator = &recordingBuildImageOrchestrator{ + ContainerOrchestrator: orchestrator, + buildOptions: make(chan containers.BuildImageOptions, 1), + } + return recordingOrchestrator + }, + }, + ) + require.NoError(t, startErr) + defer shutdownTestEnvironment(serverInfo, cancel) + + namespace := &apiv2.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "v2-pci-raw-archive"}} + require.NoError(t, serverInfo.Client.Create(ctx, namespace)) + waitObjectAssumesStateEx(t, ctx, serverInfo.Client, namespace.NamespacedName(), func(currentNamespace *apiv2.Namespace) (bool, error) { + return currentNamespace.Status.Phase == apiv2.NamespacePhaseActive, nil + }) + + targetImage := "v2-pci-raw-archive-target" + rawContents := base64.StdEncoding.EncodeToString(make([]byte, 1024)) + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "raw-archive-image", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyAlways, + Build: &apiv2.ContainerBuildContext{ + Digest: "empty-tar-v1", + ContextArchive: &apiv2.ContainerBuildContextArchive{ + RawContents: rawContents, + }, + }, + }}, + } + require.NoError(t, serverInfo.Client.Create(ctx, image)) + + waitObjectAssumesStateEx(t, ctx, serverInfo.Client, image.NamespacedName(), func(currentImage *apiv2.PhysicalContainerImage) (bool, error) { + return currentImage.Status.Phase == apiv2.PhysicalContainerImagePhaseReady, nil + }) + + buildOptions := <-recordingOrchestrator.buildOptions + require.NotNil(t, buildOptions.ContextArchive) + require.Equal(t, "empty-tar-v1", buildOptions.Digest) + require.Equal(t, rawContents, buildOptions.ContextArchive.RawContents) + require.False(t, buildOptions.Pull) +} + func TestV2PhysicalContainerImageControllerPullsSourceImage(t *testing.T) { t.Parallel() ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) @@ -46,6 +127,36 @@ func TestV2PhysicalContainerImageControllerPullsSourceImage(t *testing.T) { require.True(t, containerOrchestrator.HasImage(updatedImage.Status.Image)) } +func TestV2PhysicalContainerImageControllerUsesLocalBestEffortSourceAfterPullFailure(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + sourceImage := "v2-pci-best-effort-local-source" + imageID, pullErr := containerOrchestrator.PullImage(ctx, containers.PullImageOptions{Image: sourceImage}) + require.NoError(t, pullErr) + containerOrchestrator.FailNextPullImage(sourceImage, errors.New("pull unavailable")) + noRetries := int32(0) + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-best-effort-source") + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "best-effort-source-image", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: sourceImage, + PullPolicy: apiv2.PullPolicyBestEffort, + PullRetryLimit: &noRetries, + }}, + } + require.NoError(t, client.Create(ctx, image)) + + updatedImage := waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + require.Equal(t, imageID, updatedImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.PullImageCallCount(sourceImage)) +} + func TestV2PhysicalContainerImageControllerPreservesRemovedRuntimeImageIdentity(t *testing.T) { t.Parallel() ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) @@ -442,6 +553,673 @@ func TestV2PhysicalContainerImageControllerBuildsImage(t *testing.T) { require.Contains(t, inspectedImages[0].Tags, "v2-pci-built-target-image") } +func TestV2PhysicalContainerImageControllerReusesBuildOutputWhenInputsMatch(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + targetImage := "v2-pci-existing-build-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-existing-build") + createImage := func(name, digest, rawContents, labelValue string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyMissing, + Build: &apiv2.ContainerBuildContext{ + Digest: digest, + ContextArchive: &apiv2.ContainerBuildContextArchive{ + RawContents: rawContents, + }, + Labels: []commonapi.Label{{Key: "material-label", Value: labelValue}}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-build-image", "context-v1", "dGVzdA==", "label-v1") + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + + secondImage := createImage("second-build-image", "context-v1", "ZGlmZmVyZW50", "label-v1") + require.Equal(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + + thirdImage := createImage("third-build-image", "context-v2", "ZGlmZmVyZW50", "label-v1") + require.NotEqual(t, secondImage.Status.ImageID, thirdImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.BuildImageCallCount(targetImage)) + + fourthImage := createImage("fourth-build-image", "context-v2", "ZGlmZmVyZW50", "label-v2") + require.NotEqual(t, thirdImage.Status.ImageID, fourthImage.Status.ImageID) + require.Equal(t, 3, containerOrchestrator.BuildImageCallCount(targetImage)) +} + +func TestV2PhysicalContainerImageControllerRebuildsWhenInheritedBuildInputsChange(t *testing.T) { + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + const ( + buildArgumentName = "DCP_TEST_V2_PHYSICAL_IMAGE_BUILD_ARGUMENT" + buildSecretName = "DCP_TEST_V2_PHYSICAL_IMAGE_BUILD_SECRET" + ) + t.Setenv(buildArgumentName, "argument-one") + t.Setenv(buildSecretName, "secret-one") + + targetImage := "v2-pci-inherited-build-argument-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-inherited-build-argument") + createImage := func(name string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + Build: &apiv2.ContainerBuildContext{ + Digest: "context-v1", + ContextArchive: &apiv2.ContainerBuildContextArchive{RawContents: "dGVzdA=="}, + Args: []commonapi.EnvVar{{Name: buildArgumentName}}, + Secrets: []apiv2.ContainerBuildSecret{{ + Type: apiv2.EnvSecret, + ID: buildSecretName, + }}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-inherited-build-argument") + secondImage := createImage("second-inherited-build-argument") + require.Equal(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + + t.Setenv(buildArgumentName, "argument-two") + thirdImage := createImage("third-inherited-build-argument") + require.NotEqual(t, secondImage.Status.ImageID, thirdImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.BuildImageCallCount(targetImage)) + + t.Setenv(buildArgumentName, "argument-one") + t.Setenv(buildSecretName, "secret-two") + fourthImage := createImage("fourth-inherited-build-argument") + require.NotEqual(t, thirdImage.Status.ImageID, fourthImage.Status.ImageID) + require.Equal(t, 3, containerOrchestrator.BuildImageCallCount(targetImage)) +} + +func TestV2PhysicalContainerImageControllerReusesExplicitBuildInputsAcrossAmbientChanges(t *testing.T) { + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + const ( + buildArgumentName = "DCP_TEST_V2_PHYSICAL_IMAGE_EXPLICIT_BUILD_ARGUMENT" + buildSecretName = "DCP_TEST_V2_PHYSICAL_IMAGE_EXPLICIT_BUILD_SECRET" + ) + t.Setenv(buildArgumentName, "ambient-argument-one") + t.Setenv(buildSecretName, "ambient-secret-one") + + targetImage := "v2-pci-explicit-build-input-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-explicit-build-input") + createImage := func(name string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + Build: &apiv2.ContainerBuildContext{ + Digest: "context-v1", + ContextArchive: &apiv2.ContainerBuildContextArchive{RawContents: "dGVzdA=="}, + Args: []commonapi.EnvVar{{ + Name: buildArgumentName, + Value: "explicit-argument", + }}, + Secrets: []apiv2.ContainerBuildSecret{{ + Type: apiv2.EnvSecret, + ID: "secret", + Source: buildSecretName, + Value: "explicit-secret", + }}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-explicit-build-input") + t.Setenv(buildArgumentName, "ambient-argument-two") + t.Setenv(buildSecretName, "ambient-secret-two") + secondImage := createImage("second-explicit-build-input") + require.Equal(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) +} + +func TestV2PhysicalContainerImageControllerRebuildsWhenFileSecretChanges(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + secretPath := filepath.Join(t.TempDir(), "secret") + require.NoError(t, usvc_io.WriteFile(secretPath, []byte("secret-one"), osutil.PermissionOnlyOwnerReadWrite)) + + targetImage := "v2-pci-file-secret-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-file-secret") + createImage := func(name string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + Build: &apiv2.ContainerBuildContext{ + Digest: "context-v1", + ContextArchive: &apiv2.ContainerBuildContextArchive{RawContents: "dGVzdA=="}, + Secrets: []apiv2.ContainerBuildSecret{{ + Type: apiv2.FileSecret, + ID: "secret", + Source: secretPath, + }}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-file-secret") + secondImage := createImage("second-file-secret") + require.Equal(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + + require.NoError(t, usvc_io.WriteFile(secretPath, []byte("secret-two"), osutil.PermissionOnlyOwnerReadWrite)) + thirdImage := createImage("third-file-secret") + require.NotEqual(t, secondImage.Status.ImageID, thirdImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.BuildImageCallCount(targetImage)) +} + +func TestV2PhysicalContainerImageControllerReportsUnreadableFileSecret(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + secretPath := filepath.Join(t.TempDir(), "missing-secret") + namespace := createActiveV2Namespace(t, ctx, "v2-pci-missing-file-secret") + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "missing-file-secret", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: "v2-pci-missing-file-secret-target", + Build: &apiv2.ContainerBuildContext{ + Digest: "context-v1", + ContextArchive: &apiv2.ContainerBuildContextArchive{RawContents: "dGVzdA=="}, + Secrets: []apiv2.ContainerBuildSecret{{ + Type: apiv2.FileSecret, + ID: "secret", + Source: secretPath, + }}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + + failedImage := waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseFailed) + requireReadyCondition(t, failedImage.Status.Conditions, metav1.ConditionFalse, apiv2.PhysicalContainerImageReasonBuildFailed) + readyCondition := apimeta.FindStatusCondition(failedImage.Status.Conditions, string(apiv2.ConditionReady)) + require.NotNil(t, readyCondition) + require.Contains(t, readyCondition.Message, secretPath) +} + +func TestV2PhysicalContainerImageControllerReusesDirectoryBuildOutputByDigest(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + targetImage := "v2-pci-directory-build-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-directory-build") + createImage := func(name, contextPath, digest string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + Build: &apiv2.ContainerBuildContext{ + Context: contextPath, + Digest: digest, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-directory-build", "first-context", "context-v1") + secondImage := createImage("second-directory-build", "second-context", "context-v1") + require.Equal(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + + thirdImage := createImage("third-directory-build", "second-context", "context-v2") + require.NotEqual(t, secondImage.Status.ImageID, thirdImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.BuildImageCallCount(targetImage)) +} + +func TestV2PhysicalContainerImageControllerBuildsWithoutContextDigest(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + targetImage := "v2-pci-no-context-digest-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-no-context-digest") + createImage := func(name string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + Build: &apiv2.ContainerBuildContext{Context: "test-context"}, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-no-context-digest") + secondImage := createImage("second-no-context-digest") + require.NotEqual(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.BuildImageCallCount(targetImage)) +} + +func TestV2PhysicalContainerImageControllerRebuildsWhenBuildTagsChange(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + targetImage := "v2-pci-build-tags-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-build-tags") + createImage := func(name string, secondaryTags []string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + Build: &apiv2.ContainerBuildContext{ + Context: "test-context", + Digest: "context-v1", + Tags: secondaryTags, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-build-tags", []string{"v2-pci-secondary-a", "v2-pci-secondary-b"}) + secondImage := createImage("second-build-tags", []string{"v2-pci-secondary-b", "v2-pci-secondary-a"}) + require.Equal(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + + thirdImage := createImage("third-build-tags", []string{"v2-pci-secondary-c"}) + require.NotEqual(t, secondImage.Status.ImageID, thirdImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.BuildImageCallCount(targetImage)) + + inspectedImages, inspectErr := containerOrchestrator.InspectImages(ctx, containers.InspectImagesOptions{Images: []string{targetImage}}) + require.NoError(t, inspectErr) + require.Len(t, inspectedImages, 1) + require.Contains(t, inspectedImages[0].Tags, "v2-pci-secondary-c") +} + +func TestV2PhysicalContainerImageControllerAlwaysBuildPolicyRebuildsMatchingOutput(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + targetImage := "v2-pci-always-build-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-always-build") + createImage := func(name string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + BuildPolicy: apiv2.BuildPolicyAlways, + Build: &apiv2.ContainerBuildContext{ + Digest: "context-v1", + ContextArchive: &apiv2.ContainerBuildContextArchive{ + RawContents: "dGVzdA==", + }, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-always-build") + secondImage := createImage("second-always-build") + require.NotEqual(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.BuildImageCallCount(targetImage)) +} + +func TestV2PhysicalContainerImageControllerAlwaysPullPolicyReusesMatchingBuildOutput(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + baseImage := "v2-pci-always-pull-base" + targetImage := "v2-pci-always-pull-target" + _, pullErr := containerOrchestrator.PullImage(ctx, containers.PullImageOptions{Image: baseImage}) + require.NoError(t, pullErr) + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-always-pull-build") + createImage := func(name string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyAlways, + Build: &apiv2.ContainerBuildContext{ + Digest: "context-v1", + ContextArchive: &apiv2.ContainerBuildContextArchive{ + RawContents: "dGVzdA==", + }, + BaseImages: []string{baseImage}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-always-pull-build") + secondImage := createImage("second-always-pull-build") + require.Equal(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + require.Equal(t, 3, containerOrchestrator.PullImageCallCount(baseImage)) +} + +func TestV2PhysicalContainerImageControllerNeverPullPolicyUsesLocalBuildBase(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + baseImage := "v2-pci-never-pull-base" + targetImage := "v2-pci-never-pull-target" + _, pullErr := containerOrchestrator.PullImage(ctx, containers.PullImageOptions{Image: baseImage}) + require.NoError(t, pullErr) + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-never-pull-build") + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "never-pull-build", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyNever, + Build: &apiv2.ContainerBuildContext{ + Context: "test-context", + BaseImages: []string{baseImage}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + + waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + require.Equal(t, 1, containerOrchestrator.PullImageCallCount(baseImage)) +} + +func TestV2PhysicalContainerImageControllerMissingPullPolicyPullsMissingBuildBase(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + baseImage := "v2-pci-missing-pull-base" + targetImage := "v2-pci-missing-pull-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-missing-pull-build") + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "missing-pull-build", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyMissing, + Build: &apiv2.ContainerBuildContext{ + Context: "test-context", + BaseImages: []string{baseImage}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + + waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + require.Equal(t, 1, containerOrchestrator.PullImageCallCount(baseImage)) +} + +func TestV2PhysicalContainerImageControllerMissingPullPolicyTracksLocalBuildBaseIdentity(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + baseImage := "v2-pci-missing-pull-identity-base" + targetImage := "v2-pci-missing-pull-identity-target" + buildBaseImage := func() { + require.NoError(t, containerOrchestrator.BuildImage(ctx, containers.BuildImageOptions{ + ContainerBuildContext: &containers.ContainerBuildContext{ + Context: "base-context", + Tags: []string{baseImage}, + }, + })) + } + buildBaseImage() + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-missing-pull-identity") + createImage := func(name string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyMissing, + Build: &apiv2.ContainerBuildContext{ + Digest: "context-v1", + ContextArchive: &apiv2.ContainerBuildContextArchive{ + RawContents: "dGVzdA==", + }, + BaseImages: []string{baseImage}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-missing-pull-identity") + secondImage := createImage("second-missing-pull-identity") + require.Equal(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + + buildBaseImage() + thirdImage := createImage("third-missing-pull-identity") + require.NotEqual(t, secondImage.Status.ImageID, thirdImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.BuildImageCallCount(targetImage)) + require.Equal(t, 0, containerOrchestrator.PullImageCallCount(baseImage)) +} + +func TestV2PhysicalContainerImageControllerNeverPullPolicyFailsForMissingBuildBase(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + baseImage := "v2-pci-never-pull-missing-base" + targetImage := "v2-pci-never-pull-missing-target" + namespace := createActiveV2Namespace(t, ctx, "v2-pci-never-pull-missing-build") + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "never-pull-missing-build", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyNever, + Build: &apiv2.ContainerBuildContext{ + Context: "test-context", + BaseImages: []string{baseImage}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + + waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseFailed) + require.Equal(t, 0, containerOrchestrator.BuildImageCallCount(targetImage)) + require.Equal(t, 0, containerOrchestrator.PullImageCallCount(baseImage)) +} + +func TestV2PhysicalContainerImageControllerRebuildsWhenBestEffortBaseImageChanges(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + baseImage := "v2-pci-best-effort-base" + targetImage := "v2-pci-best-effort-target" + buildBaseImage := func() { + require.NoError(t, containerOrchestrator.BuildImage(ctx, containers.BuildImageOptions{ + ContainerBuildContext: &containers.ContainerBuildContext{ + Context: "base-context", + Tags: []string{baseImage}, + }, + })) + } + buildBaseImage() + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-best-effort-build") + createImage := func(name string) *apiv2.PhysicalContainerImage { + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyBestEffort, + Build: &apiv2.ContainerBuildContext{ + Digest: "context-v1", + ContextArchive: &apiv2.ContainerBuildContextArchive{ + RawContents: "dGVzdA==", + }, + BaseImages: []string{baseImage}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + return waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + } + + firstImage := createImage("first-best-effort-build") + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + + secondImage := createImage("second-best-effort-build") + require.Equal(t, firstImage.Status.ImageID, secondImage.Status.ImageID) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + + buildBaseImage() + thirdImage := createImage("third-best-effort-build") + require.NotEqual(t, secondImage.Status.ImageID, thirdImage.Status.ImageID) + require.Equal(t, 2, containerOrchestrator.BuildImageCallCount(targetImage)) + require.Equal(t, 3, containerOrchestrator.PullImageCallCount(baseImage)) +} + +func TestV2PhysicalContainerImageControllerUsesLocalBestEffortBaseImageAfterPullFailure(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + baseImage := "v2-pci-best-effort-local-base" + targetImage := "v2-pci-best-effort-local-target" + _, pullErr := containerOrchestrator.PullImage(ctx, containers.PullImageOptions{Image: baseImage}) + require.NoError(t, pullErr) + containerOrchestrator.FailNextPullImage(baseImage, errors.New("pull unavailable")) + noRetries := int32(0) + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-best-effort-local") + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "best-effort-local-image", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyBestEffort, + PullRetryLimit: &noRetries, + Build: &apiv2.ContainerBuildContext{ + Context: "test-context", + BaseImages: []string{baseImage}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + + waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) + require.Equal(t, 2, containerOrchestrator.PullImageCallCount(baseImage)) +} + +func TestV2PhysicalContainerImageControllerFailsWhenBestEffortBaseImageIsUnavailable(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + baseImage := "v2-pci-best-effort-missing-base" + targetImage := "v2-pci-best-effort-missing-target" + containerOrchestrator.FailNextPullImage(baseImage, errors.New("pull unavailable")) + noRetries := int32(0) + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-best-effort-missing") + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "best-effort-missing-image", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyBestEffort, + PullRetryLimit: &noRetries, + Build: &apiv2.ContainerBuildContext{ + Context: "test-context", + BaseImages: []string{baseImage}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + + waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseFailed) + require.Equal(t, 0, containerOrchestrator.BuildImageCallCount(targetImage)) + require.Equal(t, 1, containerOrchestrator.PullImageCallCount(baseImage)) +} + func TestV2PhysicalContainerImageControllerReportsMissingBuildImageID(t *testing.T) { t.Parallel() ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) @@ -585,6 +1363,134 @@ func TestV2PhysicalContainerImageControllerHonorsDisabledPullRetries(t *testing. }, 3*time.Second, 250*time.Millisecond) } +func TestV2PhysicalContainerImageControllerWaitsForHealthyRuntimeBeforePull(t *testing.T) { + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + containerOrchestrator.SetRuntimeHealth(false) + defer containerOrchestrator.SetRuntimeHealth(true) + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-runtime-gate") + sourceImage := "v2-pci-runtime-gate-source" + noRetries := int32(0) + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "runtime-gated-image", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: sourceImage, + PullPolicy: apiv2.PullPolicyAlways, + PullRetryLimit: &noRetries, + }}, + } + require.NoError(t, client.Create(ctx, image)) + + waitObjectAssumesState(t, ctx, image.NamespacedName(), func(currentImage *apiv2.PhysicalContainerImage) (bool, error) { + readyCondition := apimeta.FindStatusCondition(currentImage.Status.Conditions, string(apiv2.ConditionReady)) + return currentImage.Status.Phase == apiv2.PhysicalContainerImagePhasePending && + readyCondition != nil && + apiv2.ConditionReason(readyCondition.Reason) == apiv2.PhysicalResourceReasonContainerRuntimeUnhealthy, nil + }) + require.Equal(t, 0, containerOrchestrator.PullImageCallCount(sourceImage)) + + containerOrchestrator.SetRuntimeHealth(true) + + waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + require.Equal(t, 1, containerOrchestrator.PullImageCallCount(sourceImage)) +} + +func TestV2PhysicalContainerImageControllerPreservesPullBudgetWhenRuntimeBecomesUnhealthy(t *testing.T) { + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-runtime-pull") + sourceImage := "v2-pci-runtime-pull-source" + releasePull := containerOrchestrator.BlockPullImage(sourceImage) + defer releasePull() + + noRetries := int32(0) + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "runtime-interrupted-pull-image", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: sourceImage, + PullPolicy: apiv2.PullPolicyAlways, + PullRetryLimit: &noRetries, + }}, + } + require.NoError(t, client.Create(ctx, image)) + waitPullImageCallCount(t, ctx, sourceImage, 1) + + containerOrchestrator.SetRuntimeHealth(false) + defer containerOrchestrator.SetRuntimeHealth(true) + releasePull() + + waitObjectAssumesState(t, ctx, image.NamespacedName(), func(currentImage *apiv2.PhysicalContainerImage) (bool, error) { + readyCondition := apimeta.FindStatusCondition(currentImage.Status.Conditions, string(apiv2.ConditionReady)) + return currentImage.Status.Phase == apiv2.PhysicalContainerImagePhasePending && + readyCondition != nil && + apiv2.ConditionReason(readyCondition.Reason) == apiv2.PhysicalResourceReasonContainerRuntimeUnhealthy, nil + }) + require.Equal(t, 1, containerOrchestrator.PullImageCallCount(sourceImage)) + + containerOrchestrator.SetRuntimeHealth(true) + + waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + require.Equal(t, 2, containerOrchestrator.PullImageCallCount(sourceImage)) +} + +func TestV2PhysicalContainerImageControllerPreservesBasePullBudgetWhenRuntimeBecomesUnhealthy(t *testing.T) { + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + namespace := createActiveV2Namespace(t, ctx, "v2-pci-runtime-base-pull") + baseImage := "v2-pci-runtime-base-pull-source" + targetImage := "v2-pci-runtime-base-pull-target" + releasePull := containerOrchestrator.BlockPullImage(baseImage) + defer releasePull() + + noRetries := int32(0) + image := &apiv2.PhysicalContainerImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "runtime-interrupted-base-pull-image", + Namespace: namespace.Name, + }, + Spec: apiv2.PhysicalContainerImageSpec{Image: &apiv2.PhysicalContainerImageConfig{ + Image: targetImage, + PullPolicy: apiv2.PullPolicyAlways, + PullRetryLimit: &noRetries, + Build: &apiv2.ContainerBuildContext{ + Context: "test-context", + BaseImages: []string{baseImage}, + }, + }}, + } + require.NoError(t, client.Create(ctx, image)) + waitPullImageCallCount(t, ctx, baseImage, 1) + + containerOrchestrator.SetRuntimeHealth(false) + defer containerOrchestrator.SetRuntimeHealth(true) + releasePull() + + waitObjectAssumesState(t, ctx, image.NamespacedName(), func(currentImage *apiv2.PhysicalContainerImage) (bool, error) { + readyCondition := apimeta.FindStatusCondition(currentImage.Status.Conditions, string(apiv2.ConditionReady)) + return currentImage.Status.Phase == apiv2.PhysicalContainerImagePhasePending && + readyCondition != nil && + apiv2.ConditionReason(readyCondition.Reason) == apiv2.PhysicalResourceReasonContainerRuntimeUnhealthy, nil + }) + require.Equal(t, 1, containerOrchestrator.PullImageCallCount(baseImage)) + require.Equal(t, 0, containerOrchestrator.BuildImageCallCount(targetImage)) + + containerOrchestrator.SetRuntimeHealth(true) + + waitPhysicalContainerImagePhase(t, ctx, image.NamespacedName(), apiv2.PhysicalContainerImagePhaseReady) + require.Equal(t, 2, containerOrchestrator.PullImageCallCount(baseImage)) + require.Equal(t, 1, containerOrchestrator.BuildImageCallCount(targetImage)) +} + func TestV2PhysicalContainerImageControllerCancelsPullOnDeletion(t *testing.T) { t.Parallel() ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) diff --git a/test/integration/v2_physical_container_network_connection_test.go b/test/integration/v2_physical_container_network_connection_test.go new file mode 100644 index 00000000..f796b95a --- /dev/null +++ b/test/integration/v2_physical_container_network_connection_test.go @@ -0,0 +1,200 @@ +/*--------------------------------------------------------------------------------------------- + * 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" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + + apiv1 "github.com/microsoft/dcp/api/v1" + apiv2 "github.com/microsoft/dcp/api/v2" + "github.com/microsoft/dcp/internal/containers" + ctrl_testutil "github.com/microsoft/dcp/internal/testutil/ctrlutil" + "github.com/microsoft/dcp/pkg/testutil" +) + +func TestV2PhysicalContainerNetworkConnectionReconcilesMembership(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + namespace := createActiveV2Namespace(t, ctx, "v2-pcnc-reconcile") + networkName := "v2-pcnc-reconcile-runtime" + networkID, createNetworkErr := containerOrchestrator.CreateNetwork(ctx, containers.CreateNetworkOptions{Name: networkName}) + require.NoError(t, createNetworkErr) + removeRuntimeNetworkOnCleanup(t, networkName) + + physicalNetwork := &apiv2.PhysicalContainerNetwork{ + ObjectMeta: metav1.ObjectMeta{Name: "network", Namespace: namespace.Name}, + Spec: apiv2.PhysicalContainerNetworkSpec{NetworkID: networkID}, + } + require.NoError(t, client.Create(ctx, physicalNetwork)) + readyNetwork := waitPhysicalContainerNetworkPhase(t, ctx, physicalNetwork.NamespacedName(), apiv2.PhysicalContainerNetworkPhaseReady) + + containerID := runExistingTestContainer(t, ctx, "v2-pcnc-reconcile-container", "v2-pcnc-image") + physicalContainer := &apiv2.PhysicalContainer{ + ObjectMeta: metav1.ObjectMeta{Name: "container", Namespace: namespace.Name}, + Spec: apiv2.PhysicalContainerSpec{ContainerID: containerID}, + } + require.NoError(t, client.Create(ctx, physicalContainer)) + readyContainer := waitPhysicalContainerPhase(t, ctx, physicalContainer.NamespacedName(), apiv2.PhysicalContainerPhaseRunning) + + connection := &apiv2.PhysicalContainerNetworkConnection{ + ObjectMeta: metav1.ObjectMeta{Name: "connection", Namespace: namespace.Name}, + Spec: apiv2.PhysicalContainerNetworkConnectionSpec{ + ContainerRef: physicalContainer.Name, + NetworkRef: physicalNetwork.Name, + Aliases: []string{"physical-alias"}, + }, + } + require.NoError(t, client.Create(ctx, connection)) + + waitRuntimeContainerNetworkMembership(t, ctx, readyNetwork.Status.NetworkID, readyContainer.Status.ContainerID, true) + waitPhysicalContainerNetworkMembershipStatus(t, ctx, physicalNetwork.NamespacedName(), readyContainer.Status.ContainerID, true) + + require.NoError(t, containerOrchestrator.SimulateContainerStatus(ctx, readyContainer.Status.ContainerID, containers.ContainerStatusPaused)) + waitPhysicalContainerPhase(t, ctx, physicalContainer.NamespacedName(), apiv2.PhysicalContainerPhasePaused) + disconnectErr := containerOrchestrator.DisconnectNetwork(ctx, containers.DisconnectNetworkOptions{ + Network: readyNetwork.Status.NetworkID, + Container: readyContainer.Status.ContainerID, + }) + require.NoError(t, disconnectErr) + waitRuntimeContainerNetworkMembership(t, ctx, readyNetwork.Status.NetworkID, readyContainer.Status.ContainerID, true) + require.NoError(t, containerOrchestrator.SimulateContainerStatus(ctx, readyContainer.Status.ContainerID, containers.ContainerStatusRunning)) + waitPhysicalContainerPhase(t, ctx, physicalContainer.NamespacedName(), apiv2.PhysicalContainerPhaseRunning) + + require.NoError(t, client.Delete(ctx, connection)) + ctrl_testutil.WaitObjectDeleted(t, ctx, client, connection) + waitRuntimeContainerNetworkMembership(t, ctx, readyNetwork.Status.NetworkID, readyContainer.Status.ContainerID, false) + waitPhysicalContainerNetworkMembershipStatus(t, ctx, physicalNetwork.NamespacedName(), readyContainer.Status.ContainerID, false) + require.NoError(t, client.Delete(ctx, physicalContainer)) + require.NoError(t, client.Delete(ctx, physicalNetwork)) + ctrl_testutil.WaitObjectDeleted(t, ctx, client, physicalContainer) + ctrl_testutil.WaitObjectDeleted(t, ctx, client, physicalNetwork) +} + +func TestV1NetworkControllerPreservesV2ManagedConnection(t *testing.T) { + t.Parallel() + ctx, cancel := testutil.GetTestContext(t, defaultIntegrationTestTimeout) + defer cancel() + + network := &apiv1.ContainerNetwork{ + ObjectMeta: metav1.ObjectMeta{Name: "v1-network-with-v2-connection"}, + } + require.NoError(t, client.Create(ctx, network)) + readyV1Network := waitObjectAssumesState(t, ctx, network.NamespacedName(), func(current *apiv1.ContainerNetwork) (bool, error) { + if current.Status.State == apiv1.ContainerNetworkStateFailedToStart { + return false, fmt.Errorf("network creation failed: %s", current.Status.Message) + } + return current.Status.State == apiv1.ContainerNetworkStateRunning && current.Status.ID != "", nil + }) + + namespace := createActiveV2Namespace(t, ctx, "v1-network-v2-connection") + physicalNetwork := &apiv2.PhysicalContainerNetwork{ + ObjectMeta: metav1.ObjectMeta{Name: "network", Namespace: namespace.Name}, + Spec: apiv2.PhysicalContainerNetworkSpec{NetworkID: readyV1Network.Status.ID}, + } + require.NoError(t, client.Create(ctx, physicalNetwork)) + readyPhysicalNetwork := waitPhysicalContainerNetworkPhase(t, ctx, physicalNetwork.NamespacedName(), apiv2.PhysicalContainerNetworkPhaseReady) + + containerID := runExistingTestContainer(t, ctx, "v1-network-v2-connection-container", "v1-network-v2-connection-image") + physicalContainer := &apiv2.PhysicalContainer{ + ObjectMeta: metav1.ObjectMeta{Name: "container", Namespace: namespace.Name}, + Spec: apiv2.PhysicalContainerSpec{ContainerID: containerID}, + } + require.NoError(t, client.Create(ctx, physicalContainer)) + readyContainer := waitPhysicalContainerPhase(t, ctx, physicalContainer.NamespacedName(), apiv2.PhysicalContainerPhaseRunning) + + connection := &apiv2.PhysicalContainerNetworkConnection{ + ObjectMeta: metav1.ObjectMeta{Name: "connection", Namespace: namespace.Name}, + Spec: apiv2.PhysicalContainerNetworkConnectionSpec{ + ContainerRef: physicalContainer.Name, + NetworkRef: physicalNetwork.Name, + }, + } + require.NoError(t, client.Create(ctx, connection)) + waitRuntimeContainerNetworkMembership(t, ctx, readyPhysicalNetwork.Status.NetworkID, readyContainer.Status.ContainerID, true) + waitPhysicalContainerNetworkMembershipStatus(t, ctx, physicalNetwork.NamespacedName(), readyContainer.Status.ContainerID, true) + + _ = waitObjectAssumesState(t, ctx, network.NamespacedName(), func(current *apiv1.ContainerNetwork) (bool, error) { + for _, connectedContainerID := range current.Status.ContainerIDs { + if connectedContainerID == readyContainer.Status.ContainerID { + return true, nil + } + } + return false, nil + }) + + waitRuntimeContainerNetworkMembership(t, ctx, readyPhysicalNetwork.Status.NetworkID, readyContainer.Status.ContainerID, true) + + require.NoError(t, client.Delete(ctx, connection)) + ctrl_testutil.WaitObjectDeleted(t, ctx, client, connection) + require.NoError(t, client.Delete(ctx, physicalContainer)) + require.NoError(t, client.Delete(ctx, physicalNetwork)) + require.NoError(t, client.Delete(ctx, network)) + ctrl_testutil.WaitObjectDeleted(t, ctx, client, physicalContainer) + ctrl_testutil.WaitObjectDeleted(t, ctx, client, physicalNetwork) + ctrl_testutil.WaitObjectDeleted(t, ctx, client, network) +} + +func waitPhysicalContainerNetworkMembershipStatus( + t *testing.T, + ctx context.Context, + name types.NamespacedName, + containerID string, + expected bool, +) *apiv2.PhysicalContainerNetwork { + t.Helper() + + return waitObjectAssumesState(t, ctx, name, func(network *apiv2.PhysicalContainerNetwork) (bool, error) { + found := false + for _, connectedContainerID := range network.Status.ContainerIDs { + if connectedContainerID == containerID { + found = true + break + } + } + return found == expected, nil + }) +} + +func waitRuntimeContainerNetworkMembership( + t *testing.T, + ctx context.Context, + networkID string, + containerID string, + expected bool, +) { + t.Helper() + + waitErr := wait.PollUntilContextCancel(ctx, waitPollInterval, pollImmediately, func(ctx context.Context) (bool, error) { + inspectedNetworks, inspectErr := containerOrchestrator.InspectNetworks(ctx, containers.InspectNetworksOptions{ + Networks: []string{networkID}, + }) + if inspectErr != nil { + return false, inspectErr + } + if len(inspectedNetworks) != 1 { + return false, nil + } + + found := false + for _, container := range inspectedNetworks[0].Containers { + if container.Id == containerID { + found = true + break + } + } + return found == expected, nil + }) + require.NoError(t, waitErr) +} diff --git a/test/integration/v2_physical_container_network_controller_test.go b/test/integration/v2_physical_container_network_controller_test.go index 3562b664..a2a4c373 100644 --- a/test/integration/v2_physical_container_network_controller_test.go +++ b/test/integration/v2_physical_container_network_controller_test.go @@ -514,7 +514,7 @@ func TestV2PhysicalContainerNetworkControllerDisconnectsPreservedContainerDuring ContainerName: "v2-pcn-ns-preserved-container", RetainRuntimeContainer: true, Networks: []apiv2.ContainerNetworkConnectionConfig{ - {Name: networkName}, + {Name: network.Name}, }, }, },