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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/v2/common_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
54 changes: 51 additions & 3 deletions api/v2/container_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -230,13 +263,28 @@ 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"

// Never pull the container image.
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 (
Expand Down
1 change: 1 addition & 0 deletions api/v2/groupversion_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var (
&PhysicalContainerImage{},
&PhysicalContainer{},
&PhysicalContainerNetwork{},
&PhysicalContainerNetworkConnection{},
&PhysicalContainerVolume{},
&PhysicalProcess{},
}
Expand Down
89 changes: 81 additions & 8 deletions api/v2/physical_container_image_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand All @@ -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"), "<base64 data>", 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 == "" {
Expand Down
Loading
Loading