Skip to content
Closed
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
26 changes: 26 additions & 0 deletions api/v1/container_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,12 @@ type ContainerSpec struct {
// PEM formatted public certificates to be created in the container
// +optional
PemCertificates *ContainerPemCertificates `json:"pemCertificates,omitempty"`

// Optional terminal/PTY configuration. When set, the container's primary
// process is started under a host pseudo-terminal and its
// stdin/stdout/stderr are bridged to the configured UDS via HMP v1,
// instead of the container being run detached with separate log capture.
Comment on lines +689 to +692
Terminal *TerminalSpec `json:"terminal,omitempty"`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be included in the validation (for both initial object creation and object update) and in the lifecycle key calculation.

}

func (cs *ContainerSpec) Equal(other *ContainerSpec) bool {
Expand Down Expand Up @@ -794,6 +800,10 @@ func (cs *ContainerSpec) Equal(other *ContainerSpec) bool {
return false
}

if !cs.Terminal.Equal(other.Terminal) {
return false
}

return true
}

Expand All @@ -812,6 +822,7 @@ func initializeHashEncoder() {
_ = initEncoder.Encode(CreateFileSystem{})
_ = initEncoder.Encode(ContainerPemCertificates{})
_ = initEncoder.Encode(ImageLayer{})
_ = initEncoder.Encode(TerminalSpec{})
}

func (cs *ContainerSpec) GetLifecycleKey() (string, bool, error) {
Expand Down Expand Up @@ -1014,6 +1025,15 @@ func (cs *ContainerSpec) GetLifecycleKey() (string, bool, error) {
}
}

if cs.Terminal != nil {
// The Terminal section selects how DCP attaches stdio to the
// container (PTY + HMP v1 listener). It is forbidden to mutate
// after creation (see ContainerSpec.ValidateUpdate), so include it
// in the lifecycle key for parity with Equal — two specs that
// differ on Terminal must produce different keys.
hashErr = errors.Join(hashErr, encoder.Encode(*cs.Terminal))
}

// Compute the hash for the lifecycle key
lifecycleKey := fmt.Sprintf("%x", fnvHash.Sum(nil))

Expand Down Expand Up @@ -1315,6 +1335,10 @@ func (c *Container) Validate(ctx context.Context) field.ErrorList {
// Validate PEM certificates configuration
errorList = append(errorList, c.Spec.PemCertificates.Validate(field.NewPath("spec", "pemCertificates"))...)

// Validate terminal configuration (when set, drives PTY allocation +
// HMP v1 listener for container attach).
errorList = append(errorList, c.Spec.Terminal.Validate(field.NewPath("spec", "terminal"))...)

// Validate that annotations don't exceed the Kubernetes size limit.
// This provides a clearer error message than the generic Kubernetes API server error,
// especially when long arguments or environment variables are stored in annotations.
Expand Down Expand Up @@ -1408,6 +1432,8 @@ func (c *Container) ValidateUpdate(ctx context.Context, obj runtime.Object) fiel
}
}

errorList = append(errorList, c.Spec.Terminal.ValidateUpdate(oldContainer.Spec.Terminal, field.NewPath("spec", "terminal"))...)

return errorList
}

Expand Down
15 changes: 15 additions & 0 deletions api/v1/executable_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,13 @@ type ExecutableSpec struct {
// PEM formatted certificates to be written for the Executable
// +optional
PemCertificates *ExecutablePemCertificates `json:"pemCertificates,omitempty"`

// Terminal, when non-nil, allocates a pseudo-terminal for the
// Executable's process and exposes an HMP v1 producer endpoint that the
// Aspire terminal host connects to as a client. See TerminalSpec for
// details.
// +optional
Terminal *TerminalSpec `json:"terminal,omitempty"`
}

func (es ExecutableSpec) Equal(other ExecutableSpec) bool {
Expand Down Expand Up @@ -314,6 +321,10 @@ func (es ExecutableSpec) Equal(other ExecutableSpec) bool {
return false
}

if !es.Terminal.Equal(other.Terminal) {
return false
}

return true
}

Expand Down Expand Up @@ -355,6 +366,8 @@ func (es ExecutableSpec) Validate(specPath *field.Path) field.ErrorList {

errorList = append(errorList, es.PemCertificates.Validate(specPath.Child("pemCertificates"))...)

errorList = append(errorList, es.Terminal.Validate(specPath.Child("terminal"))...)

return errorList
}

Expand Down Expand Up @@ -539,6 +552,8 @@ func (e *Executable) ValidateUpdate(ctx context.Context, obj runtime.Object) fie
errorList = append(errorList, field.Forbidden(field.NewPath("spec", "pemCertificates"), "pemCertificates cannot be changed once an Executable is created."))
}

errorList = append(errorList, e.Spec.Terminal.ValidateUpdate(oldExe.Spec.Terminal, field.NewPath("spec", "terminal"))...)

return errorList
}

Expand Down
108 changes: 108 additions & 0 deletions api/v1/terminal_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------------------------------------------*/

package v1

import (
"k8s.io/apimachinery/pkg/util/validation/field"
)

// TerminalSpec configures pseudo-terminal allocation for an Executable or
// Container replica and the HMP v1 producer endpoint that the Aspire terminal
// host connects to as a client.
//
// Presence of this field on an Executable or Container spec activates the
// terminal path: DCP allocates a PTY for the underlying process and listens on
// UDSPath. When the terminal host opens an HMP v1 connection, DCP starts an
// HMP v1 server on the connection and bridges:
Comment on lines +16 to +19
//
// - PTY output (from the process's tty) -> HMP v1 Output frames
// - HMP v1 Input frames -> PTY input (process stdin)
// - HMP v1 Resize frames -> PTY resize (TIOCSWINSZ / ResizePseudoConsole)
// - Process exit -> HMP v1 Exit frame, then close
//
// The HMP v1 wire format is defined by the Aspire dashboard's terminal host.
// See the canonical spec at:
//
// https://github.com/microsoft/aspire/blob/main/docs/specs/with-terminal.md
//
// DCP's responsibility is limited to PTY allocation, the listener, and frame
// translation.
type TerminalSpec struct {
// UDSPath is the Unix Domain Socket path that DCP listens on for the
// terminal host's HMP v1 client connection. Required.
UDSPath string `json:"udsPath,omitempty"`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to support changing UDSPath for existing Container or Executable? If yes, we should handle it in the controller(s). If not we should prevent that change via validation.


Comment on lines +33 to +37
// Cols is the initial width of the pseudo-terminal in character columns.
// If zero, a sensible default (80) is used.
// +kubebuilder:default:=0
Cols int32 `json:"cols,omitempty"`

// Rows is the initial height of the pseudo-terminal in character rows.
// If zero, a sensible default (24) is used.
// +kubebuilder:default:=0
Rows int32 `json:"rows,omitempty"`
}

// Equal reports whether two TerminalSpec values are equal.
func (ts *TerminalSpec) Equal(other *TerminalSpec) bool {
if ts == other {
return true
}
if ts == nil || other == nil {
return false
}
return ts.UDSPath == other.UDSPath &&
ts.Cols == other.Cols &&
ts.Rows == other.Rows
}

// Validate verifies the TerminalSpec content.
func (ts *TerminalSpec) Validate(specPath *field.Path) field.ErrorList {
errorList := field.ErrorList{}
if ts == nil {
return errorList
}
if ts.UDSPath == "" {
errorList = append(errorList, field.Invalid(specPath.Child("udsPath"), ts.UDSPath, "udsPath is required."))
}
if ts.Cols < 0 {
errorList = append(errorList, field.Invalid(specPath.Child("cols"), ts.Cols, "cols must be non-negative."))
}
if ts.Rows < 0 {
errorList = append(errorList, field.Invalid(specPath.Child("rows"), ts.Rows, "rows must be non-negative."))
}
return errorList
}

// ValidateUpdate verifies that the new TerminalSpec is a permissible update
// from the previous one. The terminal is wired up at process/container
// startup and is bound to the listener owned by the terminal host; mutating
// it after the fact would require tearing down and re-establishing the PTY
// session, which the runtime does not support today. So we forbid all
// post-creation changes (including adding or removing the terminal).
func (ts *TerminalSpec) ValidateUpdate(old *TerminalSpec, specPath *field.Path) field.ErrorList {
errorList := field.ErrorList{}
if old.Equal(ts) {
return errorList
}
errorList = append(errorList, field.Forbidden(specPath, "terminal cannot be changed after the resource is created."))
return errorList
}

// DeepCopyInto copies the receiver, writing into out.
func (in *TerminalSpec) DeepCopyInto(out *TerminalSpec) {
*out = *in
}

// DeepCopy returns a deep copy of the TerminalSpec.
func (in *TerminalSpec) DeepCopy() *TerminalSpec {
if in == nil {
return nil
}
out := new(TerminalSpec)
in.DeepCopyInto(out)
return out
}
8 changes: 8 additions & 0 deletions api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions controllers/container_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,13 @@
if inspected.Status == containers.ContainerStatusRunning {
log.V(1).Info("Container started")
rcd.containerState = apiv1.ContainerStateRunning

if err := r.ensureContainerTerminalSession(startupCtx, rcd, log); err != nil {

Check failure on line 1485 in controllers/container_controller.go

View workflow job for this annotation

GitHub Actions / Lint ubuntu-latest

shadow: declaration of "err" shadows declaration at line 1117 (govet)
// Terminal attach failure is non-fatal: the container is
// running and observable via the orchestrator, but no
// interactive PTY is available. We log and continue.
log.Error(err, "Failed to attach terminal session to running container; container is running but interactive terminal will be unavailable")
}
} else {
log.V(1).Info("Container started and exited shortly after", "ContainerStatus", inspected.Status)
rcd.containerState = apiv1.ContainerStateExited
Expand Down Expand Up @@ -1557,6 +1564,7 @@
// or if the container has already finished starting/stopping and we know the outcome of either.

defer rcd.deleteStartupLogFiles(log)
defer rcd.closeTerminalSession(log)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the terminal supposed to be working if the container is stopped/exited? If not, this probably belongs to stopContainerIfNecessary()


if container.Spec.Persistent {
log.V(1).Info("Container is not using Managed mode, leaving underlying resources")
Expand Down Expand Up @@ -1588,6 +1596,53 @@
r.ReleaseContainerWatchForResource(container.UID, log)
}

// ensureContainerTerminalSession attaches a host-side PTY to the running
// container and starts the HMP v1 listener at the configured UDS path,
// storing the resulting session on rcd. No-op if the container does not
// have terminal enabled or a session is already active.
//
// The container must have been created with `-t -i` for the attach to
// deliver a usable terminal; that is handled by applyCreateContainerOptions
// in the docker/podman orchestrator when ContainerSpec.Terminal is set.
//
// Errors here are non-fatal to the container lifecycle: the container is
// already running by the time this is called. The caller is expected to
// log the error and move on.
func (r *ContainerReconciler) ensureContainerTerminalSession(
Comment on lines +1599 to +1611
ctx context.Context,
rcd *runningContainerData,
log logr.Logger,
) error {
if rcd == nil || rcd.runSpec == nil {
return nil
}

terminal := rcd.runSpec.Terminal
if terminal == nil {
return nil
}

if rcd.terminalSession != nil {
return nil
}

if !rcd.hasValidContainerID() {
return errors.New("ensureContainerTerminalSession called without a valid container ID")
}

runner, ok := r.orchestrator.(containers.CLICommandRunner)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should just make CLICommandRunner part of ContainerOrchestrator interface so this kind of casting is not necessary.

if !ok {
return fmt.Errorf("container orchestrator %T does not implement containers.CLICommandRunner; terminal attach not supported", r.orchestrator)
}

session, err := startContainerTerminalSession(ctx, runner, string(rcd.containerID), terminal, log)
if err != nil {
return err
}
rcd.terminalSession = session
return nil
}

func (r *ContainerReconciler) startContainerWithTimeout(
parentCtx context.Context,
containerName string,
Expand Down
Loading
Loading