feat: improve Kubernetes worker lifecycle visibility (#3553)

This commit is contained in:
Carlos Florêncio
2026-09-10 11:05:05 +01:00
committed by GitHub
parent 796bf5853f
commit 2858109308
59 changed files with 4364 additions and 262 deletions
@@ -0,0 +1,148 @@
package kubernetes
import (
"os"
"path/filepath"
"runtime"
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
func TestKubernetesWorkerPresetsCompose(t *testing.T) {
t.Parallel()
for _, name := range []string{"minimal", "node-pnpm", "python"} {
name := name
t.Run(name, func(t *testing.T) {
t.Parallel()
raw := readWorkerPreset(t, name)
template, err := ParsePodTemplate(raw)
if err != nil {
t.Fatalf("ParsePodTemplate() error = %v", err)
}
profile := ProfileConfig{
Platform: PlatformLinuxAMD64,
MainContainer: DefaultMainContainerName,
PodTemplateYAML: raw,
Workspace: WorkspaceConfig{
Mode: WorkspaceModeManagedPVC,
Size: "10Gi",
AccessModes: []string{"ReadWriteOnce"},
},
}
if err := profile.Validate(); err != nil {
t.Fatalf("ProfileConfig.Validate() error = %v", err)
}
pod, _, err := ComposePod(template, profile, PodOptions{
Name: "kandev-session-1", Namespace: "agents",
Identity: ResourceIdentity{
ExecutorID: "executor-1", ProfileID: "profile-1", InstanceID: "instance-1",
TaskID: "task-1", SessionID: "session-1", EnvironmentID: "environment-1",
},
Command: []string{"/bin/sh"}, Args: []string{"-ceu", "run-managed-entrypoint"},
WorkingDir: WorkspaceMountPath, ManagedPVCName: "workspace-pvc",
})
if err != nil {
t.Fatalf("ComposePod() error = %v", err)
}
assertWorkerPresetTemplate(t, template)
assertWorkerPresetResources(t, pod)
})
}
}
func TestKubernetesWorkerPresetsPreserveRuntimeOwnership(t *testing.T) {
t.Parallel()
for _, name := range []string{"minimal", "node-pnpm", "python"} {
name := name
t.Run(name, func(t *testing.T) {
t.Parallel()
template, err := ParsePodTemplate(readWorkerPreset(t, name))
if err != nil {
t.Fatalf("ParsePodTemplate() error = %v", err)
}
main := findWorkerPresetContainer(t, template.Template.Spec.Containers)
if main.SecurityContext == nil || main.SecurityContext.AllowPrivilegeEscalation == nil ||
*main.SecurityContext.AllowPrivilegeEscalation {
t.Fatal("preset must disable privilege escalation")
}
if main.SecurityContext.Capabilities == nil || len(main.SecurityContext.Capabilities.Drop) != 1 ||
main.SecurityContext.Capabilities.Drop[0] != corev1.Capability("ALL") {
t.Fatalf("preset capabilities = %#v, want drop ALL", main.SecurityContext.Capabilities)
}
if main.Env != nil {
for _, env := range main.Env {
if env.Name == "HOME" || env.Name == "KANDEV_HOME_DIR" {
t.Fatalf("preset owns runtime environment %q", env.Name)
}
}
}
})
}
}
func readWorkerPreset(t *testing.T, name string) string {
t.Helper()
_, source, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
root := filepath.Join(filepath.Dir(source), "../../../../..")
raw, err := os.ReadFile(filepath.Join(root, "k8s", "presets", name+".yaml"))
if err != nil {
t.Fatalf("read %s preset: %v", name, err)
}
return string(raw)
}
func assertWorkerPresetTemplate(t *testing.T, template *corev1.PodTemplate) {
t.Helper()
if template.Template.Spec.AutomountServiceAccountToken == nil ||
*template.Template.Spec.AutomountServiceAccountToken {
t.Fatal("preset must disable service-account token automount")
}
if template.Template.Spec.RestartPolicy != "" {
t.Fatalf("preset owns restartPolicy %q", template.Template.Spec.RestartPolicy)
}
if template.Template.Spec.SecurityContext == nil ||
template.Template.Spec.SecurityContext.RunAsNonRoot == nil ||
!*template.Template.Spec.SecurityContext.RunAsNonRoot ||
template.Template.Spec.SecurityContext.RunAsUser == nil ||
*template.Template.Spec.SecurityContext.RunAsUser != 1000 ||
template.Template.Spec.SecurityContext.FSGroup == nil ||
*template.Template.Spec.SecurityContext.FSGroup != 1000 {
t.Fatalf("preset pod security context = %#v", template.Template.Spec.SecurityContext)
}
main := findWorkerPresetContainer(t, template.Template.Spec.Containers)
if main.Command != nil || main.Args != nil || main.WorkingDir != "" || len(main.Ports) != 0 ||
len(main.VolumeMounts) != 0 {
t.Fatalf("preset owns Kandev launch fields: command=%v args=%v workingDir=%q ports=%v mounts=%v", main.Command, main.Args, main.WorkingDir, main.Ports, main.VolumeMounts)
}
}
func assertWorkerPresetResources(t *testing.T, pod *corev1.Pod) {
t.Helper()
main := findWorkerPresetContainer(t, pod.Spec.Containers)
if main.Resources.Requests.Cpu().Cmp(resource.MustParse("250m")) != 0 ||
main.Resources.Requests.Memory().Cmp(resource.MustParse("512Mi")) != 0 {
t.Fatalf("resource requests = %v, want 250m CPU and 512Mi memory", main.Resources.Requests)
}
if main.Resources.Limits.Memory().Cmp(resource.MustParse("2Gi")) != 0 {
t.Fatalf("memory limit = %v, want 2Gi", main.Resources.Limits.Memory())
}
}
func findWorkerPresetContainer(t *testing.T, containers []corev1.Container) *corev1.Container {
t.Helper()
for i := range containers {
if containers[i].Name == DefaultMainContainerName {
return &containers[i]
}
}
t.Fatal("preset does not contain kandev-agent")
return nil
}
@@ -45,11 +45,12 @@ var errKubernetesLifecycleRequestIncomplete = errors.New("kubernetes lifecycle r
// KubernetesExecutor owns Kubernetes Pod/PVC lifecycle and process-local
// forwards. Cluster clients are initialized lazily from the selected executor.
type KubernetesExecutor struct {
agentctlResolver *AgentctlResolver
logger *logger.Logger
clientFactory kubernetesRuntimeClientFactory
resolveBinary kubernetesAgentctlBinaryResolver
healthRetryDelay time.Duration
agentctlResolver *AgentctlResolver
logger *logger.Logger
clientFactory kubernetesRuntimeClientFactory
resolveBinary kubernetesAgentctlBinaryResolver
healthRetryDelay time.Duration
launchTimingClock func() time.Time
mu sync.Mutex
sessions map[string]*kubernetesSession
@@ -79,12 +80,13 @@ const (
func NewKubernetesExecutor(agentctlResolver *AgentctlResolver, log *logger.Logger) *KubernetesExecutor {
runtime := &KubernetesExecutor{
agentctlResolver: agentctlResolver,
logger: log,
clientFactory: newKubernetesRuntimeClient,
healthRetryDelay: agentctlHealthRetryDelay,
sessions: make(map[string]*kubernetesSession),
locks: make(map[string]*kubernetesInstanceLock),
agentctlResolver: agentctlResolver,
logger: log,
clientFactory: newKubernetesRuntimeClient,
healthRetryDelay: agentctlHealthRetryDelay,
launchTimingClock: time.Now,
sessions: make(map[string]*kubernetesSession),
locks: make(map[string]*kubernetesInstanceLock),
}
if agentctlResolver != nil {
runtime.resolveBinary = func(platform kubeexecutor.Platform) ([]byte, error) {
@@ -149,36 +151,64 @@ func (r *KubernetesExecutor) createFresh(
executorConfig kubeexecutor.ExecutorConfig,
profile kubeexecutor.ProfileConfig,
) (_ *ExecutorInstance, returnedErr error) {
timing := newKubernetesLaunchTimingWithClock(r.logger, req, r.launchTimingClock)
defer func() { timing.complete(returnedErr) }()
launch, err := newKubernetesFreshLaunch(r, runtime, req, executorConfig, profile)
if err != nil {
timing.failureSite = "initialize"
return nil, err
}
defer func() {
returnedErr = launch.rollbackAfterFailure(ctx, returnedErr)
}()
if err := launch.provisionWorkspace(ctx); err != nil {
if err := timing.runStage("storage", func() error {
return launch.provisionWorkspace(ctx)
}); err != nil {
return nil, err
}
runningPod, err := launch.createRunningPod(ctx)
var runningPod *corev1.Pod
if err := timing.runStage("pod_ready", func() error {
var stageErr error
runningPod, stageErr = launch.createRunningPod(ctx)
return stageErr
}); err != nil {
return nil, err
}
var nonce string
var binary []byte
if err := timing.runStage("bootstrap", func() error {
var stageErr error
nonce, stageErr = generateBootstrapNonce()
if stageErr != nil {
return stageErr
}
binary, stageErr = r.resolveBinary(profile.Platform)
if stageErr != nil {
return fmt.Errorf("kubernetes lifecycle: resolve agentctl for %s: %w", profile.Platform, stageErr)
}
return r.bootstrapPod(ctx, runtime, req, runningPod, profile, nonce, binary)
}); err != nil {
return nil, err
}
var client *agentctl.Client
var finalForward kubeexecutor.PortForwardSession
var token string
var remotePort int
if err := timing.runStage("agentctl_connect", func() error {
var stageErr error
client, finalForward, token, remotePort, stageErr = r.connectNewAgentctl(
ctx, runtime, req, runningPod, nonce,
)
return stageErr
}); err != nil {
return nil, err
}
instance, err := launch.complete(ctx, runningPod, client, finalForward, token, nonce, remotePort)
if err != nil {
return nil, err
timing.failureSite = "finalize"
}
nonce, err := generateBootstrapNonce()
if err != nil {
return nil, err
}
binary, err := r.resolveBinary(profile.Platform)
if err != nil {
return nil, fmt.Errorf("kubernetes lifecycle: resolve agentctl for %s: %w", profile.Platform, err)
}
if err = r.bootstrapPod(ctx, runtime, req, runningPod, profile, nonce, binary); err != nil {
return nil, err
}
client, finalForward, token, remotePort, err := r.connectNewAgentctl(ctx, runtime, req, runningPod, nonce)
if err != nil {
return nil, err
}
return launch.complete(ctx, runningPod, client, finalForward, token, nonce, remotePort)
return instance, err
}
type kubernetesFreshLaunch struct {
@@ -0,0 +1,123 @@
package lifecycle
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
const (
kubernetesLaunchOutcomeSuccess = "success"
kubernetesLaunchOutcomeError = "error"
kubernetesLaunchOutcomeCanceled = "canceled"
kubernetesLaunchOutcomeTimeout = "timeout"
)
type kubernetesLaunchTiming struct {
logger *logger.Logger
now func() time.Time
startedAt time.Time
attemptID string
instanceID string
taskID string
sessionID string
failedStage string
failureSite string
}
func newKubernetesLaunchTiming(log *logger.Logger, req *ExecutorCreateRequest) *kubernetesLaunchTiming {
return newKubernetesLaunchTimingWithClock(log, req, time.Now)
}
func newKubernetesLaunchTimingWithClock(
log *logger.Logger,
req *ExecutorCreateRequest,
now func() time.Time,
) *kubernetesLaunchTiming {
if now == nil {
now = time.Now
}
timing := &kubernetesLaunchTiming{
logger: log,
now: now,
startedAt: now(),
attemptID: uuid.NewString(),
}
if req != nil {
timing.instanceID = req.InstanceID
timing.taskID = req.TaskID
timing.sessionID = req.SessionID
}
return timing
}
func (t *kubernetesLaunchTiming) runStage(stage string, action func() error) error {
startedAt := t.now()
err := action()
t.recordStage(stage, startedAt, err)
return err
}
func (t *kubernetesLaunchTiming) recordStage(stage string, startedAt time.Time, err error) {
if err != nil && t.failedStage == "" {
t.failedStage = stage
}
if t.logger == nil {
return
}
t.logger.Info("kubernetes.launch.stage", append(
t.commonFields(t.elapsedMilliseconds(startedAt), kubernetesLaunchTimingOutcome(err)),
zap.String("stage", stage),
)...)
}
func (t *kubernetesLaunchTiming) complete(err error) {
if t.logger == nil {
return
}
fields := t.commonFields(t.elapsedMilliseconds(t.startedAt), kubernetesLaunchTimingOutcome(err))
if t.failedStage != "" {
fields = append(fields, zap.String("failed_stage", t.failedStage))
} else if t.failureSite != "" {
fields = append(fields, zap.String("failure_site", t.failureSite))
}
t.logger.Info("kubernetes.launch.completed", fields...)
}
func (t *kubernetesLaunchTiming) commonFields(duration int64, outcome string) []zap.Field {
return []zap.Field{
zap.String("attempt_id", t.attemptID),
zap.String("instance_id", t.instanceID),
zap.String("task_id", t.taskID),
zap.String("session_id", t.sessionID),
zap.String("mode", "fresh"),
zap.Int64("duration_ms", duration),
zap.String("outcome", outcome),
}
}
func (t *kubernetesLaunchTiming) elapsedMilliseconds(startedAt time.Time) int64 {
duration := t.now().Sub(startedAt).Milliseconds()
if duration < 0 {
return 0
}
return duration
}
func kubernetesLaunchTimingOutcome(err error) string {
switch {
case err == nil:
return kubernetesLaunchOutcomeSuccess
case errors.Is(err, context.DeadlineExceeded):
return kubernetesLaunchOutcomeTimeout
case errors.Is(err, context.Canceled):
return kubernetesLaunchOutcomeCanceled
default:
return kubernetesLaunchOutcomeError
}
}
@@ -0,0 +1,232 @@
package lifecycle
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
corev1 "k8s.io/api/core/v1"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/common/logger"
)
func TestKubernetesLaunchTimingSuccess(t *testing.T) {
executor, logs := observedKubernetesExecutor(t)
executor.launchTimingClock = sequenceKubernetesTimingClock(
time.Date(2026, time.September, 9, 10, 0, 0, 0, time.UTC),
10,
10*time.Millisecond,
)
instance, err := executor.CreateInstance(context.Background(), validKubernetesCreateRequest())
require.NoError(t, err)
require.NotNil(t, instance)
stages := kubernetesTimingEntries(logs, "kubernetes.launch.stage")
require.Len(t, stages, 4)
require.Equal(t, []string{"storage", "pod_ready", "bootstrap", "agentctl_connect"},
[]string{
stages[0].ContextMap()["stage"].(string),
stages[1].ContextMap()["stage"].(string),
stages[2].ContextMap()["stage"].(string),
stages[3].ContextMap()["stage"].(string),
})
for _, entry := range stages {
fields := entry.ContextMap()
require.Equal(t, "success", fields["outcome"])
require.EqualValues(t, 10, fields["duration_ms"])
require.Equal(t, "fresh", fields["mode"])
}
completed := kubernetesTimingEntries(logs, "kubernetes.launch.completed")
require.Len(t, completed, 1)
require.Equal(t, "success", completed[0].ContextMap()["outcome"])
require.EqualValues(t, 90, completed[0].ContextMap()["duration_ms"])
require.NotEmpty(t, completed[0].ContextMap()["attempt_id"])
require.Equal(t, "instance-1", completed[0].ContextMap()["instance_id"])
require.Equal(t, "task-1", completed[0].ContextMap()["task_id"])
require.Equal(t, "session-1", completed[0].ContextMap()["session_id"])
}
func TestKubernetesLaunchTimingFailures(t *testing.T) {
t.Parallel()
tests := []struct {
name string
configure func(*ExecutorCreateRequest, *fakeKubernetesResources, *recordingKubernetesExec)
failedStage string
outcome string
}{
{
name: "storage",
configure: func(req *ExecutorCreateRequest, resources *fakeKubernetesResources, _ *recordingKubernetesExec) {
setManagedKubernetesWorkspace(req)
resources.createPVCBeforeCommitErr = errors.New("storage failed")
},
failedStage: "storage",
outcome: "error",
},
{
name: "pod readiness",
configure: func(_ *ExecutorCreateRequest, resources *fakeKubernetesResources, _ *recordingKubernetesExec) {
resources.waitForPodRunning = func(context.Context, string, string, string) (*corev1.Pod, error) {
return nil, errors.New("pod readiness failed")
}
},
failedStage: "pod_ready",
outcome: "error",
},
{
name: "bootstrap cancellation",
configure: func(_ *ExecutorCreateRequest, _ *fakeKubernetesResources, execs *recordingKubernetesExec) {
execs.err = context.Canceled
},
failedStage: "bootstrap",
outcome: "canceled",
},
{
name: "agentctl connection",
configure: func(_ *ExecutorCreateRequest, _ *fakeKubernetesResources, _ *recordingKubernetesExec) {},
failedStage: "agentctl_connect",
outcome: "error",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
resources := &fakeKubernetesResources{}
execs := &recordingKubernetesExec{}
executor, logs := observedKubernetesExecutorWith(
t, resources, execs, &recordingKubernetesForwarder{localPorts: map[uint16]uint16{}},
)
req := validKubernetesCreateRequest()
tt.configure(req, resources, execs)
_, err := executor.CreateInstance(context.Background(), req)
require.Error(t, err)
stages := kubernetesTimingEntries(logs, "kubernetes.launch.stage")
require.NotEmpty(t, stages)
lastStage := stages[len(stages)-1].ContextMap()
require.Equal(t, tt.failedStage, lastStage["stage"])
require.Equal(t, tt.outcome, lastStage["outcome"])
completed := kubernetesTimingEntries(logs, "kubernetes.launch.completed")
require.Len(t, completed, 1)
require.Equal(t, tt.outcome, completed[0].ContextMap()["outcome"])
require.Equal(t, tt.failedStage, completed[0].ContextMap()["failed_stage"])
})
}
}
func TestKubernetesLaunchTimingFinalization(t *testing.T) {
executor, logs := observedKubernetesExecutor(t)
finalizationErr := errors.New("finalization contained sentinel-secret")
req := validKubernetesCreateRequest()
req.CheckpointRuntimeInventory = func(_ context.Context, metadata map[string]interface{}) error {
if getMetadataString(metadata, MetadataKeyKubernetesInventoryState) ==
KubernetesInventoryStateReady {
return finalizationErr
}
return nil
}
_, err := executor.CreateInstance(context.Background(), req)
require.ErrorIs(t, err, finalizationErr)
stages := kubernetesTimingEntries(logs, "kubernetes.launch.stage")
require.Len(t, stages, 4)
completed := kubernetesTimingEntries(logs, "kubernetes.launch.completed")
require.Len(t, completed, 1)
require.Equal(t, "finalize", completed[0].ContextMap()["failure_site"])
require.NotContains(t, strings.Join(logMessages(logs), "\n"), "sentinel-secret")
}
func TestKubernetesLaunchTimingConcurrentAttempts(t *testing.T) {
timingOne := newKubernetesLaunchTiming(nil, &ExecutorCreateRequest{
InstanceID: "instance-one", TaskID: "task-one", SessionID: "session-one",
})
timingTwo := newKubernetesLaunchTiming(nil, &ExecutorCreateRequest{
InstanceID: "instance-two", TaskID: "task-two", SessionID: "session-two",
})
var wait sync.WaitGroup
for _, timing := range []*kubernetesLaunchTiming{timingOne, timingTwo} {
wait.Add(1)
go func(timing *kubernetesLaunchTiming) {
defer wait.Done()
timing.recordStage("storage", time.Now(), nil)
timing.complete(nil)
}(timing)
}
wait.Wait()
require.NotEqual(t, timingOne.attemptID, timingTwo.attemptID)
require.Equal(t, "instance-one", timingOne.instanceID)
require.Equal(t, "instance-two", timingTwo.instanceID)
}
func observedKubernetesExecutor(t *testing.T) (*KubernetesExecutor, *observer.ObservedLogs) {
t.Helper()
controlPort := startKubernetesAgentctlServer(t, true, 41001)
instancePort := startKubernetesAgentctlServer(t, false, 0)
return observedKubernetesExecutorWith(
t,
&fakeKubernetesResources{},
&recordingKubernetesExec{},
&recordingKubernetesForwarder{localPorts: map[uint16]uint16{
uint16(8765): controlPort,
41001: instancePort,
}},
)
}
func observedKubernetesExecutorWith(
t *testing.T,
resources *fakeKubernetesResources,
execs *recordingKubernetesExec,
forwards *recordingKubernetesForwarder,
) (*KubernetesExecutor, *observer.ObservedLogs) {
t.Helper()
core, logs := observer.New(zapcore.InfoLevel)
log, err := logger.NewFromZap(zap.New(core))
require.NoError(t, err)
executor := newFakeKubernetesExecutorWithForwarder(resources, execs, forwards)
executor.logger = log
executor.healthRetryDelay = time.Nanosecond
return executor, logs
}
func sequenceKubernetesTimingClock(start time.Time, calls int, step time.Duration) func() time.Time {
var mu sync.Mutex
index := 0
return func() time.Time {
mu.Lock()
defer mu.Unlock()
if index >= calls {
return start.Add(time.Duration(calls) * step)
}
current := start.Add(time.Duration(index) * step)
index++
return current
}
}
func kubernetesTimingEntries(logs *observer.ObservedLogs, message string) []observer.LoggedEntry {
return logs.FilterMessage(message).All()
}
func logMessages(logs *observer.ObservedLogs) []string {
entries := logs.All()
messages := make([]string, 0, len(entries))
for _, entry := range entries {
messages = append(messages, entry.Message)
}
return messages
}
+22 -38
View File
@@ -19,15 +19,18 @@ import (
// SessionRow is a sanitized projection of one Kubernetes-backed running executor.
type SessionRow struct {
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
PodName string `json:"pod_name,omitempty"`
PodPhase string `json:"pod_phase,omitempty"`
ContainerState string `json:"container_state,omitempty"`
Restarts int32 `json:"restarts"`
WorkspaceKind string `json:"workspace_kind,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
PodName string `json:"pod_name,omitempty"`
PodPhase string `json:"pod_phase,omitempty"`
ContainerState string `json:"container_state,omitempty"`
Restarts int32 `json:"restarts"`
WorkspaceKind string `json:"workspace_kind,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
SessionState string `json:"session_state,omitempty"`
RetentionState string `json:"retention_state,omitempty"`
MainContainerRequests *MainContainerRequests `json:"main_container_requests,omitempty"`
}
// SessionImpact is the authoritative mutation impact of one Kubernetes executor.
@@ -186,6 +189,7 @@ func (h *Handler) sessionRow(
return SessionRow{}, false, err
}
row := newInventorySessionRow(run)
row.SessionState = projectedTaskSessionState(session.State)
if inventoryFailure := validateSessionInventory(run, executorID, row); inventoryFailure != "" {
row.FailureReason = inventoryFailure
return row, true, nil
@@ -194,22 +198,26 @@ func (h *Handler) sessionRow(
pod, err := client.CoreV1().Pods(namespace).Get(ctx, row.PodName, metav1.GetOptions{})
if err != nil {
row.FailureReason = podLookupFailure(err)
if apierrors.IsNotFound(err) {
row.RetentionState = kubernetesRetentionMissing
}
return row, true, nil
}
if !matchesSessionIdentity(pod, run) {
row.FailureReason = "Pod identity does not match runtime inventory"
return row, true, nil
}
populatePodStatus(&row, pod, metadataString(run.Metadata, metadataMainContainer))
populatePodStatus(&row, pod, metadataString(run.Metadata, metadataMainContainer), session.State)
return row, true, nil
}
func newInventorySessionRow(run *models.ExecutorRunning) SessionRow {
row := SessionRow{
SessionID: run.SessionID,
TaskID: run.TaskID,
PodName: metadataString(run.Metadata, metadataPodName),
WorkspaceKind: metadataString(run.Metadata, metadataWorkspaceMode),
SessionID: run.SessionID,
TaskID: run.TaskID,
PodName: metadataString(run.Metadata, metadataPodName),
WorkspaceKind: metadataString(run.Metadata, metadataWorkspaceMode),
RetentionState: kubernetesRetentionUnknown,
}
if !run.CreatedAt.IsZero() {
row.CreatedAt = run.CreatedAt.UTC().Format(time.RFC3339)
@@ -279,30 +287,6 @@ func recordedResourceIdentity(metadata map[string]interface{}) (agentkubernetes.
return identity, true
}
func populatePodStatus(row *SessionRow, pod *corev1.Pod, mainContainer string) {
row.PodPhase = string(pod.Status.Phase)
row.ContainerState = "unknown"
failureReason := pod.Status.Reason
for _, status := range pod.Status.ContainerStatuses {
if status.Name != mainContainer {
continue
}
row.Restarts = status.RestartCount
switch {
case status.State.Running != nil:
row.ContainerState = "running"
case status.State.Waiting != nil:
row.ContainerState = "waiting"
failureReason = status.State.Waiting.Reason
case status.State.Terminated != nil:
row.ContainerState = "terminated"
failureReason = status.State.Terminated.Reason
}
break
}
row.FailureReason = sanitizedPodReason(failureReason)
}
func metadataString(metadata map[string]interface{}, key string) string {
value, _ := metadata[key].(string)
return strings.TrimSpace(value)
@@ -44,6 +44,7 @@ func TestListSessionsUsesRecordedNamespaceAfterExecutorNamespaceChange(t *testin
SessionID: "session-1", TaskID: "task-1", PodName: "pod-1",
PodPhase: "Running", ContainerState: "running", Restarts: 2,
WorkspaceKind: "empty_dir", CreatedAt: createdAt.Format(time.RFC3339),
SessionState: "RUNNING", RetentionState: "active",
}}, rows)
require.Len(t, clientset.Actions(), 1)
getAction, ok := clientset.Actions()[0].(k8stesting.GetAction)
@@ -0,0 +1,147 @@
package kubernetes
import (
corev1 "k8s.io/api/core/v1"
"github.com/kandev/kandev/internal/task/models"
)
const (
kubernetesRetentionActive = "active"
kubernetesRetentionRetained = "retained"
kubernetesRetentionTerminating = "terminating"
kubernetesRetentionTerminal = "terminal"
kubernetesRetentionMissing = "missing"
kubernetesRetentionUnknown = "unknown"
)
// MainContainerRequests contains only configured requests from the verified
// Kandev main container. It does not represent observed usage or capacity.
type MainContainerRequests struct {
CPU string `json:"cpu,omitempty"`
Memory string `json:"memory,omitempty"`
}
func projectedTaskSessionState(state models.TaskSessionState) string {
if isKnownTaskSessionState(state) {
return string(state)
}
return kubernetesRetentionUnknown
}
func isKnownTaskSessionState(state models.TaskSessionState) bool {
for _, known := range models.AllTaskSessionStates {
if state == known {
return true
}
}
return false
}
func populatePodStatus(
row *SessionRow,
pod *corev1.Pod,
mainContainer string,
sessionState models.TaskSessionState,
) {
row.PodPhase = string(pod.Status.Phase)
row.ContainerState = "unknown"
failureReason := pod.Status.Reason
mainSpecFound := false
for _, container := range pod.Spec.Containers {
if container.Name != mainContainer {
continue
}
mainSpecFound = true
row.MainContainerRequests = requestsFromContainer(container)
break
}
if !mainSpecFound {
row.FailureReason = "Main container status is unavailable"
return
}
mainStatusFound := false
for _, status := range pod.Status.ContainerStatuses {
if status.Name != mainContainer {
continue
}
mainStatusFound = true
row.Restarts = status.RestartCount
switch {
case status.State.Running != nil:
row.ContainerState = "running"
case status.State.Waiting != nil:
row.ContainerState = "waiting"
failureReason = status.State.Waiting.Reason
case status.State.Terminated != nil:
row.ContainerState = "terminated"
failureReason = status.State.Terminated.Reason
}
break
}
if !mainStatusFound && failureReason == "" {
failureReason = "Main container status is unavailable"
}
row.FailureReason = sanitizedPodReason(failureReason)
row.RetentionState = kubernetesRetentionState(sessionState, pod)
}
func requestsFromContainer(container corev1.Container) *MainContainerRequests {
requests := &MainContainerRequests{}
if quantity, ok := container.Resources.Requests[corev1.ResourceCPU]; ok {
requests.CPU = quantity.String()
}
if quantity, ok := container.Resources.Requests[corev1.ResourceMemory]; ok {
requests.Memory = quantity.String()
}
if requests.CPU == "" && requests.Memory == "" {
return nil
}
return requests
}
func kubernetesRetentionState(sessionState models.TaskSessionState, pod *corev1.Pod) string {
if !isKnownTaskSessionState(sessionState) || pod == nil {
return kubernetesRetentionUnknown
}
if pod.DeletionTimestamp != nil {
return kubernetesRetentionTerminating
}
switch pod.Status.Phase {
case corev1.PodSucceeded, corev1.PodFailed:
return kubernetesRetentionTerminal
case corev1.PodPending, corev1.PodRunning:
if isRetainedTaskSessionState(sessionState) {
return kubernetesRetentionRetained
}
if isActiveTaskSessionState(sessionState) {
return kubernetesRetentionActive
}
}
return kubernetesRetentionUnknown
}
func isRetainedTaskSessionState(state models.TaskSessionState) bool {
switch state {
case models.TaskSessionStateCancelled,
models.TaskSessionStateCompleted,
models.TaskSessionStateFailed,
models.TaskSessionStateIdle:
return true
default:
return false
}
}
func isActiveTaskSessionState(state models.TaskSessionState) bool {
switch state {
case models.TaskSessionStateCreated,
models.TaskSessionStateStarting,
models.TaskSessionStateRunning,
models.TaskSessionStateWaitingForInput:
return true
default:
return false
}
}
@@ -0,0 +1,210 @@
package kubernetes
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kubernetesfake "k8s.io/client-go/kubernetes/fake"
agentkubernetes "github.com/kandev/kandev/internal/agent/kubernetes"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/task/models"
ws "github.com/kandev/kandev/pkg/websocket"
)
func TestKubernetesSessionRetentionProjectionTable(t *testing.T) {
tests := []struct {
name string
sessionState models.TaskSessionState
podPhase corev1.PodPhase
deleting bool
retentionState string
}{
{name: "created pending", sessionState: models.TaskSessionStateCreated, podPhase: corev1.PodPending, retentionState: "active"},
{name: "starting running", sessionState: models.TaskSessionStateStarting, podPhase: corev1.PodRunning, retentionState: "active"},
{name: "running pod", sessionState: models.TaskSessionStateRunning, podPhase: corev1.PodRunning, retentionState: "active"},
{name: "waiting running", sessionState: models.TaskSessionStateWaitingForInput, podPhase: corev1.PodRunning, retentionState: "active"},
{name: "cancelled running", sessionState: models.TaskSessionStateCancelled, podPhase: corev1.PodRunning, retentionState: "retained"},
{name: "idle running", sessionState: models.TaskSessionStateIdle, podPhase: corev1.PodRunning, retentionState: "retained"},
{name: "completed running", sessionState: models.TaskSessionStateCompleted, podPhase: corev1.PodRunning, retentionState: "retained"},
{name: "deleting active", sessionState: models.TaskSessionStateRunning, podPhase: corev1.PodRunning, deleting: true, retentionState: "terminating"},
{name: "succeeded retained state", sessionState: models.TaskSessionStateCancelled, podPhase: corev1.PodSucceeded, retentionState: "terminal"},
{name: "failed retained state", sessionState: models.TaskSessionStateFailed, podPhase: corev1.PodFailed, retentionState: "terminal"},
{name: "unknown session", sessionState: models.TaskSessionState("future-state"), podPhase: corev1.PodRunning, retentionState: "unknown"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
run := kubernetesRunningRow("instance-1", "session-1", "task-1", "pod-1", "pod-uid-1", time.Now())
session := kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")
session.State = test.sessionState
pod := kubernetesOwnedPod("pod-1", "pod-uid-1", "instance-1", session)
pod.Status.Phase = test.podPhase
if test.deleting {
deletionTime := metav1.Now()
pod.DeletionTimestamp = &deletionTime
}
pod.Spec.Containers = []corev1.Container{{
Name: "kandev-agent",
Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("250m"),
corev1.ResourceMemory: resource.MustParse("512Mi"),
}},
}}
repo := &fakeResourceRepository{
executor: &models.Executor{ID: "executor-1", Type: models.ExecutorTypeKubernetes, Config: validHandlerExecutorConfig()},
runs: []*models.ExecutorRunning{run}, sessions: map[string]*models.TaskSession{"session-1": session},
}
clientset := kubernetesfake.NewSimpleClientset(pod)
handler := retentionProjectionHandler(repo, clientset)
row, visible, err := handler.sessionRow(context.Background(), clientset, "executor-1", run)
require.NoError(t, err)
require.True(t, visible)
wantSessionState := string(test.sessionState)
if !isKnownTaskSessionState(test.sessionState) {
wantSessionState = "unknown"
}
require.Equal(t, wantSessionState, row.SessionState)
require.Equal(t, test.retentionState, row.RetentionState)
require.Equal(t, &MainContainerRequests{CPU: "250m", Memory: "512Mi"}, row.MainContainerRequests)
})
}
}
func TestKubernetesRetentionProjectionOmitsUnverifiedRequests(t *testing.T) {
tests := []struct {
name string
pod *corev1.Pod
mainContainer string
retentionState string
failureReason string
}{
{name: "missing pod", retentionState: "missing", failureReason: "Pod not found"},
{name: "identity mismatch", pod: kubernetesOwnedPod("pod-1", "foreign-uid", "instance-1", kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")), retentionState: "unknown", failureReason: "Pod identity does not match runtime inventory"},
{name: "main container missing", pod: kubernetesOwnedPod("pod-1", "pod-uid-1", "instance-1", kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")), mainContainer: "missing-container", retentionState: "unknown", failureReason: "Main container status is unavailable"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
run := kubernetesRunningRow("instance-1", "session-1", "task-1", "pod-1", "pod-uid-1", time.Now())
session := kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")
session.State = models.TaskSessionStateCancelled
if test.mainContainer != "" {
run.Metadata[metadataMainContainer] = test.mainContainer
}
repo := &fakeResourceRepository{
executor: &models.Executor{ID: "executor-1", Type: models.ExecutorTypeKubernetes, Config: validHandlerExecutorConfig()},
runs: []*models.ExecutorRunning{run}, sessions: map[string]*models.TaskSession{"session-1": session},
}
var clientset *kubernetesfake.Clientset
if test.pod == nil {
clientset = kubernetesfake.NewSimpleClientset()
} else {
test.pod.Spec.Containers = []corev1.Container{{
Name: "kandev-agent",
Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("1"),
corev1.ResourceMemory: resource.MustParse("1Gi"),
}},
}}
clientset = kubernetesfake.NewSimpleClientset(test.pod)
}
handler := retentionProjectionHandler(repo, clientset)
row, visible, err := handler.sessionRow(context.Background(), clientset, "executor-1", run)
require.NoError(t, err)
require.True(t, visible)
require.Equal(t, string(models.TaskSessionStateCancelled), row.SessionState)
require.Equal(t, test.retentionState, row.RetentionState)
require.Nil(t, row.MainContainerRequests)
require.Equal(t, test.failureReason, row.FailureReason)
})
}
}
func TestKubernetesMainContainerRequestsPreserveExplicitZero(t *testing.T) {
run := kubernetesRunningRow("instance-1", "session-1", "task-1", "pod-1", "pod-uid-1", time.Now())
session := kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")
session.State = models.TaskSessionStateRunning
pod := kubernetesOwnedPod("pod-1", "pod-uid-1", "instance-1", session)
pod.Spec.Containers = []corev1.Container{{
Name: "sidecar", Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("9"), corev1.ResourceMemory: resource.MustParse("9Gi"),
}},
}, {
Name: "kandev-agent", Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("0"), corev1.ResourceMemory: resource.MustParse("512Mi"),
}},
}}
repo := &fakeResourceRepository{
executor: &models.Executor{ID: "executor-1", Type: models.ExecutorTypeKubernetes, Config: validHandlerExecutorConfig()},
runs: []*models.ExecutorRunning{run}, sessions: map[string]*models.TaskSession{"session-1": session},
}
clientset := kubernetesfake.NewSimpleClientset(pod)
handler := retentionProjectionHandler(repo, clientset)
row, visible, err := handler.sessionRow(context.Background(), clientset, "executor-1", run)
require.NoError(t, err)
require.True(t, visible)
require.Equal(t, &MainContainerRequests{CPU: "0", Memory: "512Mi"}, row.MainContainerRequests)
}
func TestKubernetesRetentionProjectionSerializesOverHTTPAndWebSocket(t *testing.T) {
run := kubernetesRunningRow("instance-1", "session-1", "task-1", "pod-1", "pod-uid-1", time.Now())
session := kubernetesTaskSession("session-1", "task-1", "executor-1", "profile-1")
session.State = models.TaskSessionStateCancelled
pod := kubernetesOwnedPod("pod-1", "pod-uid-1", "instance-1", session)
pod.Spec.Containers = []corev1.Container{{Name: "kandev-agent", Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("250m")},
}}}
repo := &fakeResourceRepository{
executor: &models.Executor{ID: "executor-1", Type: models.ExecutorTypeKubernetes, Config: validHandlerExecutorConfig()},
runs: []*models.ExecutorRunning{run}, sessions: map[string]*models.TaskSession{"session-1": session},
}
clientset := kubernetesfake.NewSimpleClientset(pod)
handler := retentionProjectionHandler(repo, clientset)
router := gin.New()
router.Use(func(c *gin.Context) {
authn.SetOnGin(c, authn.Identity{UserID: "member-1", Role: authn.RoleMember})
c.Next()
})
handler.registerHTTP(router)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/kubernetes/executors/executor-1/sessions", nil))
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
var rows []SessionRow
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &rows))
require.Len(t, rows, 1)
require.Equal(t, "CANCELLED", rows[0].SessionState)
require.Equal(t, "retained", rows[0].RetentionState)
require.Equal(t, &MainContainerRequests{CPU: "250m"}, rows[0].MainContainerRequests)
dispatcher := ws.NewDispatcher()
handler.registerWS(dispatcher)
message, err := ws.NewRequest("req-1", "kubernetes.sessions.list", map[string]string{"executor_id": "executor-1"})
require.NoError(t, err)
response, err := dispatcher.Dispatch(authn.WithIdentity(context.Background(), authn.Identity{
UserID: "member-1", Role: authn.RoleMember,
}), message)
require.NoError(t, err)
require.Equal(t, ws.MessageTypeResponse, response.Type)
require.Contains(t, string(response.Payload), `"retention_state":"retained"`)
}
func retentionProjectionHandler(repo *fakeResourceRepository, clientset *kubernetesfake.Clientset) *Handler {
return NewHandler(repo, &fakeAccessChecker{}, func(agentkubernetes.ExecutorConfig) (*agentkubernetes.Client, error) {
return &agentkubernetes.Client{Clientset: clientset}, nil
})
}
@@ -67,6 +67,7 @@ func TestHTTPListSessionsAllowsMemberAndGetsOnlyAuthorizedRecordedPods(t *testin
SessionID: "session-1", TaskID: "task-1", PodName: "pod-1",
PodPhase: "Running", ContainerState: "running", Restarts: 2,
WorkspaceKind: "empty_dir", CreatedAt: createdAt.Format(time.RFC3339),
SessionState: "RUNNING", RetentionState: "active",
}}, rows)
require.Equal(t, []string{"task-1", "task-2"}, access.calls)
require.Len(t, clientset.Actions(), 1)
@@ -455,6 +456,7 @@ func TestListSessionsFailsClosedOnPodIdentityMismatch(t *testing.T) {
require.Equal(t, []SessionRow{{
SessionID: "session-1", TaskID: "task-1", PodName: "pod-1",
WorkspaceKind: "empty_dir", CreatedAt: createdAt.Format(time.RFC3339),
SessionState: "RUNNING", RetentionState: "unknown",
FailureReason: "Pod identity does not match runtime inventory",
}}, rows)
}
@@ -489,6 +491,7 @@ func TestListSessionsUsesAuthoritativeRunningExecutorAfterSessionRepoint(t *test
SessionID: "session-1", TaskID: "task-1", PodName: "pod-1",
PodPhase: "Running", ContainerState: "running", Restarts: 2,
WorkspaceKind: "empty_dir", CreatedAt: createdAt.Format(time.RFC3339),
SessionState: "RUNNING", RetentionState: "active",
}}, rows)
}
@@ -710,6 +713,7 @@ func kubernetesTaskSession(sessionID, taskID, executorID, profileID string) *mod
return &models.TaskSession{
ID: sessionID, TaskID: taskID, ExecutorID: executorID,
ExecutorProfileID: profileID, TaskEnvironmentID: "environment-1",
State: models.TaskSessionStateRunning,
}
}
@@ -736,6 +740,7 @@ func kubernetesOwnedPodForIdentity(
ObjectMeta: metav1.ObjectMeta{
Name: podName, Namespace: "kandev", UID: types.UID(podUID), Labels: labels,
},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "kandev-agent"}}},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
ContainerStatuses: []corev1.ContainerStatus{{
@@ -0,0 +1,132 @@
"use client";
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { IconChevronDown } from "@tabler/icons-react";
import AppLink from "@/components/routing/app-link";
import type { KubernetesSession } from "@/lib/types/http-kubernetes";
import {
formatCreatedAt,
shortId,
taskHref,
translateTaskLink,
workspaceLabel,
} from "./kubernetes-session-utils";
import {
RequestSummary,
SessionDetails,
SessionIdentity,
SessionRuntimeDetails,
SessionStatusSummary,
} from "./kubernetes-session-presentation";
export function MobileSessionList({ sessions }: { sessions: KubernetesSession[] }) {
const { t } = useTranslation();
return (
<div className="space-y-3" data-testid="kubernetes-mobile-session-list">
{sessions.map((session) => (
<AppLink
key={session.session_id}
href={taskHref(session.task_id)}
aria-label={translateTaskLink(session, t)}
data-testid="kubernetes-session-task-link"
className="block min-h-11 min-w-0 cursor-pointer space-y-2.5 rounded-md border p-3 text-foreground transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<SessionIdentity session={session} />
<SessionStatusSummary session={session} />
<RequestSummary session={session} showLabel />
<SessionDetails session={session} />
</AppLink>
))}
</div>
);
}
export function DesktopSessionTable({ sessions }: { sessions: KubernetesSession[] }) {
const { t } = useTranslation();
const [expandedSessionId, setExpandedSessionId] = useState<string | null>(null);
return (
<div className="max-w-full overflow-x-auto overscroll-x-contain">
<Table className="min-w-[880px]" data-testid="kubernetes-sessions-table">
<TableHeader>
<TableRow>
<TableHead>{t("executors:task")}</TableHead>
<TableHead>{t("executors:session")}</TableHead>
<TableHead>{t("executors:kubernetesPod")}</TableHead>
<TableHead>{t("executors:status")}</TableHead>
<TableHead>{t("executors:kubernetesRequests")}</TableHead>
<TableHead className="text-right">{t("executors:kubernetesCreated")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => {
const expanded = expandedSessionId === session.session_id;
return (
<Fragment key={session.session_id}>
<TableRow data-testid="kubernetes-session-row">
<TableCell className="whitespace-nowrap py-2 font-mono text-xs">
<AppLink
href={taskHref(session.task_id)}
aria-label={translateTaskLink(session, t)}
data-testid="kubernetes-session-task-link"
className="cursor-pointer underline-offset-2 hover:underline"
>
{shortId(session.task_id)}
</AppLink>
</TableCell>
<TableCell className="whitespace-nowrap py-2 font-mono text-xs">
{shortId(session.session_id)}
</TableCell>
<TableCell className="max-w-56 py-2">
<p className="truncate font-mono text-xs">{session.pod_name || "-"}</p>
<p className="mt-1 whitespace-nowrap text-xs text-muted-foreground">
{workspaceLabel(session.workspace_kind, t)}
</p>
</TableCell>
<TableCell className="py-2" data-testid="kubernetes-session-status">
<div className="flex min-w-max items-start gap-1">
<SessionStatusSummary session={session} />
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-expanded={expanded}
aria-label={t(
expanded
? "executors:kubernetesHideSessionDetails"
: "executors:kubernetesShowSessionDetails",
)}
className="cursor-pointer"
onClick={() => setExpandedSessionId(expanded ? null : session.session_id)}
>
<IconChevronDown
aria-hidden="true"
className={`transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</Button>
</div>
</TableCell>
<TableCell className="whitespace-nowrap py-2">
<RequestSummary session={session} />
</TableCell>
<TableCell className="whitespace-nowrap py-2 text-right text-xs">
{formatCreatedAt(session.created_at)}
</TableCell>
</TableRow>
{expanded && (
<TableRow data-testid="kubernetes-session-details-row">
<TableCell colSpan={6} className="bg-muted/20 py-3">
<SessionRuntimeDetails session={session} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,161 @@
"use client";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import type { KubernetesSession } from "@/lib/types/http-kubernetes";
import {
normalizedState,
mainContainerStateValue,
podPhaseValue,
podStatusLabel,
retentionLabel,
sessionStateLabel,
formatCreatedAt,
workspaceLabel,
} from "./kubernetes-session-utils";
export function SessionIdentity({ session }: { session: KubernetesSession }) {
const { t } = useTranslation();
return (
<div className="min-w-0 space-y-1.5">
<p className="break-all font-mono text-sm">{session.pod_name || "-"}</p>
<SessionIdentityValue label={t("executors:task")} value={session.task_id} />
<SessionIdentityValue label={t("executors:session")} value={session.session_id} />
</div>
);
}
function SessionIdentityValue({ label, value }: { label: string; value: string }) {
return (
<p className="min-w-0 break-all font-mono text-xs text-muted-foreground">
<span className="font-sans">{label}: </span>
{value}
</p>
);
}
export function SessionStatusSummary({ session }: { session: KubernetesSession }) {
const { t } = useTranslation();
const sessionState = normalizedState(session.session_state);
const retentionState = normalizedState(session.retention_state);
const podPhase = podPhaseValue(session);
const mainContainerState = mainContainerStateValue(session);
return (
<div className="min-w-0 space-y-1.5" data-testid="kubernetes-session-status-summary">
<div className="flex flex-nowrap items-center gap-1.5">
<Badge variant={sessionState === "running" ? "default" : "secondary"}>
{sessionStateLabel(sessionState, t)}
</Badge>
<Badge variant={retentionState === "retained" ? "outline" : "secondary"}>
{retentionLabel(retentionState, t)}
</Badge>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground md:flex-nowrap md:whitespace-nowrap">
<span>
{t("executors:kubernetesPodCompactValue", { value: podStatusLabel(podPhase, t) })}
</span>
<span aria-hidden="true">·</span>
<span>
{t("executors:kubernetesContainerCompactValue", {
value: podStatusLabel(mainContainerState, t),
})}
</span>
{session.restarts > 0 && (
<>
<span aria-hidden="true">·</span>
<RestartCount count={session.restarts} />
</>
)}
</div>
</div>
);
}
export function SessionRuntimeDetails({ session }: { session: KubernetesSession }) {
const { t } = useTranslation();
const retentionState = normalizedState(session.retention_state);
const podPhase = podPhaseValue(session);
const mainContainerState = mainContainerStateValue(session);
return (
<div
className="grid gap-x-6 gap-y-1 text-xs text-muted-foreground md:grid-cols-2 xl:grid-cols-4"
data-testid="kubernetes-session-runtime-details"
>
<p>
{t("executors:kubernetesSessionStateValue", {
value: sessionStateLabel(session.session_state, t),
})}
</p>
<p>
{t("executors:kubernetesRetentionStateValue", {
value: retentionLabel(retentionState, t),
})}
</p>
<p>{t("executors:kubernetesPodStateValue", { value: podStatusLabel(podPhase, t) })}</p>
<p>
{t("executors:kubernetesContainerStateValue", {
value: podStatusLabel(mainContainerState, t),
})}
</p>
<p>{t("executors:kubernetesRestarts", { count: session.restarts })}</p>
<p>
{t("executors:kubernetesWorkspaceValue", {
value: workspaceLabel(session.workspace_kind, t),
})}
</p>
<p>{t("executors:kubernetesCreatedValue", { value: formatCreatedAt(session.created_at) })}</p>
<RequestSummary session={session} showLabel />
<SessionFailureReason session={session} />
</div>
);
}
export function RequestSummary({
session,
showLabel = false,
}: {
session: KubernetesSession;
showLabel?: boolean;
}) {
const { t } = useTranslation();
const requests = session.main_container_requests;
const values = [
requests?.cpu ? t("executors:kubernetesRequestCpu", { value: requests.cpu }) : null,
requests?.memory ? t("executors:kubernetesRequestMemory", { value: requests.memory }) : null,
].filter((value): value is string => Boolean(value));
return (
<p
className="min-w-0 text-xs text-muted-foreground"
data-testid="kubernetes-session-request-summary"
>
{showLabel && <span>{t("executors:kubernetesMainContainerRequests")}: </span>}
{values.length > 0 ? values.join(" · ") : t("executors:kubernetesRequestsUnspecifiedValue")}
</p>
);
}
function RestartCount({ count }: { count: number }) {
const { t } = useTranslation();
return <span>{t("executors:kubernetesRestarts", { count })}</span>;
}
export function SessionDetails({ session }: { session: KubernetesSession }) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<p>
{t("executors:kubernetesWorkspaceValue", {
value: workspaceLabel(session.workspace_kind, t),
})}
</p>
<p>{t("executors:kubernetesCreatedValue", { value: formatCreatedAt(session.created_at) })}</p>
<SessionFailureReason session={session} />
</div>
);
}
export function SessionFailureReason({ session }: { session: KubernetesSession }) {
return session.failure_reason ? (
<p className="break-words text-xs text-destructive">{session.failure_reason}</p>
) : null;
}
@@ -0,0 +1,100 @@
import type { TFunction } from "i18next";
import { formatDateTime } from "@/lib/i18n/formats";
import type { KubernetesSession } from "@/lib/types/http-kubernetes";
const POD_STATUS_LABEL_KEYS: Record<string, string> = {
running: "executors:kubernetesStatusRunning",
waiting: "executors:kubernetesStatusWaiting",
terminated: "executors:kubernetesStatusTerminated",
unknown: "executors:kubernetesStatusUnknown",
pending: "executors:kubernetesStatusPending",
succeeded: "executors:kubernetesStatusSucceeded",
failed: "executors:kubernetesStatusFailed",
};
const RETENTION_LABEL_KEYS: Record<string, string> = {
active: "executors:kubernetesRetentionActive",
retained: "executors:kubernetesRetentionRetained",
terminating: "executors:kubernetesRetentionTerminating",
terminal: "executors:kubernetesRetentionTerminal",
missing: "executors:kubernetesRetentionMissing",
unknown: "executors:kubernetesRetentionUnknown",
};
const SESSION_STATE_LABEL_KEYS: Record<string, string> = {
created: "executors:kubernetesSessionCreated",
starting: "executors:kubernetesSessionStarting",
running: "executors:kubernetesSessionRunning",
idle: "executors:kubernetesSessionIdle",
waiting_for_input: "executors:kubernetesSessionWaitingForInput",
completed: "executors:kubernetesSessionCompleted",
failed: "executors:kubernetesSessionFailed",
cancelled: "executors:kubernetesSessionCancelled",
unknown: "executors:kubernetesStatusUnknown",
};
export function workspaceLabel(value: string | undefined, t: TFunction): string {
const keys: Record<string, string> = {
managed_pvc: "executors:kubernetesWorkspaceManagedPvc",
empty_dir: "executors:kubernetesWorkspaceEmptyDir",
existing_claim: "executors:kubernetesWorkspaceExistingClaim",
};
return value && keys[value] ? t(keys[value]) : value || "-";
}
export function podStatusLabel(value: string, t: TFunction): string {
const key = POD_STATUS_LABEL_KEYS[value.toLowerCase()];
return key ? t(key) : t("executors:kubernetesStatusUnknown");
}
export function retentionLabel(value: string, t: TFunction): string {
return t(RETENTION_LABEL_KEYS[value] ?? RETENTION_LABEL_KEYS.unknown);
}
export function sessionStateLabel(value: string | undefined, t: TFunction): string {
const state = normalizedState(value);
return t(SESSION_STATE_LABEL_KEYS[state] ?? SESSION_STATE_LABEL_KEYS.unknown);
}
export function normalizedState(value?: string): string {
return value?.trim().toLowerCase() || "unknown";
}
export function podStatusValue(session: KubernetesSession): string {
if (
session.container_state?.toLowerCase() === "terminated" &&
session.pod_phase?.toLowerCase() === "succeeded"
) {
return session.pod_phase;
}
return session.container_state || session.pod_phase || "unknown";
}
export function podPhaseValue(session: KubernetesSession): string {
return session.pod_phase?.trim() || "unknown";
}
export function mainContainerStateValue(session: KubernetesSession): string {
return session.container_state?.trim() || "unknown";
}
export function translateTaskLink(session: KubernetesSession, t: TFunction): string {
return t("executors:kubernetesOpenTask", {
task: session.task_id,
session: session.session_id,
});
}
export function taskHref(taskId: string): string {
return `/t/${encodeURIComponent(taskId)}`;
}
export function shortId(value: string): string {
return value.slice(0, 8);
}
export function formatCreatedAt(value?: string): string {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : formatDateTime(date);
}
@@ -0,0 +1,95 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { KubernetesSessionsCard } from "./kubernetes-sessions-card";
const responsive = vi.hoisted(() => ({ isMobile: false }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: responsive.isMobile }),
}));
afterEach(cleanup);
describe("KubernetesSessionsCard", () => {
it("shows retained state and main-container requests on both layouts", () => {
const state = sessionsState();
const rendered = render(<KubernetesSessionsCard state={state} />);
expect(screen.getByTestId("kubernetes-sessions-table").textContent).toContain("Retained");
expect(screen.getByTestId("kubernetes-session-request-summary").textContent).toContain(
"0 CPU · 512Mi memory",
);
expect(screen.getByTestId("kubernetes-session-guidance").textContent).toContain(
"Stop preserves Kubernetes resources",
);
expect(screen.getByTestId("kubernetes-session-guidance").textContent).toContain(
"Existing claims are not deleted by Kandev",
);
const desktopLink = screen.getByTestId("kubernetes-session-task-link");
expect(desktopLink.getAttribute("href")).toBe("/t/task-retained");
expect(desktopLink.getAttribute("aria-label")).toBe(
"Open task task-retained, session session-retained",
);
responsive.isMobile = true;
rendered.rerender(<KubernetesSessionsCard state={state} />);
const mobile = screen.getByTestId("kubernetes-mobile-session-list");
expect(within(mobile).getByText("Retained")).toBeTruthy();
expect(within(mobile).getByTestId("kubernetes-session-request-summary").textContent).toContain(
"0 CPU · 512Mi memory",
);
const mobileLink = within(mobile).getByRole("link");
expect(mobileLink.getAttribute("href")).toBe("/t/task-retained");
expect(mobileLink.getAttribute("aria-label")).toBe(
"Open task task-retained, session session-retained",
);
expect(mobileLink.className).toContain("cursor-pointer");
});
it("shows Pod phase and main-container state separately on both layouts", () => {
responsive.isMobile = false;
const state = sessionsState();
state.sessions[0] = {
...state.sessions[0],
pod_phase: "Pending",
container_state: "waiting",
};
const rendered = render(<KubernetesSessionsCard state={state} />);
const desktopStatus = screen.getByTestId("kubernetes-session-status");
expect(within(desktopStatus).getByText("Pod: Pending")).toBeTruthy();
expect(within(desktopStatus).getByText("Container: Waiting")).toBeTruthy();
responsive.isMobile = true;
rendered.rerender(<KubernetesSessionsCard state={state} />);
const mobile = screen.getByTestId("kubernetes-mobile-session-list");
expect(within(mobile).getByText("Pod: Pending")).toBeTruthy();
expect(within(mobile).getByText("Container: Waiting")).toBeTruthy();
});
});
type KubernetesSessionsState = ComponentProps<typeof KubernetesSessionsCard>["state"];
function sessionsState(): KubernetesSessionsState {
return {
sessions: [
{
task_id: "task-retained",
session_id: "session-retained",
pod_name: "kandev-retained",
pod_phase: "Running",
container_state: "running",
restarts: 0,
workspace_kind: "managed_pvc",
created_at: "2026-08-24T10:00:00Z",
session_state: "CANCELLED",
retention_state: "retained",
main_container_requests: { cpu: "0", memory: "512Mi" },
},
],
loading: false,
error: null,
refresh: vi.fn(async () => []),
};
}
@@ -1,29 +1,15 @@
"use client";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { IconLoader2 } from "@tabler/icons-react";
import { useKubernetesSessions } from "@/hooks/domains/settings/use-kubernetes-settings";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { DesktopSessionTable, MobileSessionList } from "./kubernetes-session-lists";
import { SettingsCard } from "./settings-card";
import { SettingsCardHeader } from "./settings-card-header";
import { settingsActionClassName } from "./settings-control";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { useKubernetesSessions } from "@/hooks/domains/settings/use-kubernetes-settings";
import { formatDateTime } from "@/lib/i18n/formats";
import { i18n, t as translate } from "@/lib/i18n";
import type { KubernetesSession } from "@/lib/types/http-kubernetes";
const STATUS_LABEL_KEYS: Record<string, string> = {
running: "executors:kubernetesStatusRunning",
waiting: "executors:kubernetesStatusWaiting",
terminated: "executors:kubernetesStatusTerminated",
unknown: "executors:kubernetesStatusUnknown",
pending: "executors:kubernetesStatusPending",
succeeded: "executors:kubernetesStatusSucceeded",
failed: "executors:kubernetesStatusFailed",
};
type KubernetesSessionsState = ReturnType<typeof useKubernetesSessions>;
@@ -54,6 +40,7 @@ export function KubernetesSessionsCard({ state }: { state: KubernetesSessionsSta
}
/>
<CardContent className="min-w-0">
{!state.error && <SessionGuidance />}
{Boolean(state.error) && (
<p className="break-words text-sm text-destructive">{errorMessage}</p>
)}
@@ -74,158 +61,14 @@ export function KubernetesSessionsCard({ state }: { state: KubernetesSessionsSta
);
}
function MobileSessionList({ sessions }: { sessions: KubernetesSession[] }) {
return (
<div className="space-y-3" data-testid="kubernetes-mobile-session-list">
{sessions.map((session) => (
<div key={session.session_id} className="min-w-0 space-y-3 rounded-md border p-3">
<SessionIdentity session={session} />
<SessionStatus session={session} />
<SessionDetails session={session} />
</div>
))}
</div>
);
}
function DesktopSessionTable({ sessions }: { sessions: KubernetesSession[] }) {
function SessionGuidance() {
const { t } = useTranslation();
return (
<div className="max-w-full overflow-x-auto overscroll-x-contain">
<Table data-testid="kubernetes-sessions-table">
<TableHeader>
<TableRow>
<TableHead>{t("executors:task")}</TableHead>
<TableHead>{t("executors:session")}</TableHead>
<TableHead>{t("executors:kubernetesPod")}</TableHead>
<TableHead>{t("executors:status")}</TableHead>
<TableHead>{t("executors:kubernetesWorkspace")}</TableHead>
<TableHead>{t("executors:kubernetesCreated")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => (
<TableRow key={session.session_id}>
<TableCell className="font-mono text-xs">{shortId(session.task_id)}</TableCell>
<TableCell className="font-mono text-xs">{shortId(session.session_id)}</TableCell>
<TableCell className="max-w-56 break-all font-mono text-xs">
{session.pod_name || "-"}
</TableCell>
<TableCell
className="max-w-64 whitespace-normal"
data-testid="kubernetes-session-status"
>
<div className="space-y-1">
<SessionStatus session={session} />
<SessionFailureReason session={session} />
</div>
</TableCell>
<TableCell className="text-xs">{workspaceLabel(session.workspace_kind)}</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{formatCreatedAt(session.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p
className="mb-3 break-words text-xs text-muted-foreground"
data-testid="kubernetes-session-guidance"
>
{t("executors:kubernetesActiveSessionsGuidance")}
</p>
);
}
function SessionIdentity({ session }: { session: KubernetesSession }) {
const { t } = useTranslation();
return (
<div className="min-w-0 space-y-2">
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{t("executors:kubernetesPod")}</p>
<p className="break-all font-mono text-sm">{session.pod_name || "-"}</p>
</div>
<div className="grid min-w-0 gap-2 sm:grid-cols-2">
<SessionIdentityValue label={t("executors:task")} value={session.task_id} />
<SessionIdentityValue label={t("executors:session")} value={session.session_id} />
</div>
</div>
);
}
function SessionIdentityValue({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="break-all font-mono text-xs">{value}</p>
</div>
);
}
function SessionStatus({ session }: { session: KubernetesSession }) {
const status =
session.container_state?.toLowerCase() === "terminated" &&
session.pod_phase?.toLowerCase() === "succeeded"
? session.pod_phase
: session.container_state || session.pod_phase || "unknown";
const running = status.toLowerCase() === "running";
return (
<div className="flex flex-wrap items-center gap-2">
<Badge variant={running ? "default" : "secondary"}>{statusLabel(status)}</Badge>
{session.restarts > 0 && <RestartCount count={session.restarts} />}
</div>
);
}
function RestartCount({ count }: { count: number }) {
const { t } = useTranslation();
return (
<span className="text-xs text-muted-foreground">
{t("executors:kubernetesRestarts", { count })}
</span>
);
}
function SessionDetails({ session }: { session: KubernetesSession }) {
const { t } = useTranslation();
return (
<div className="space-y-1 text-xs text-muted-foreground">
<p>
{t("executors:kubernetesWorkspaceValue", {
value: workspaceLabel(session.workspace_kind),
})}
</p>
<p>{t("executors:kubernetesCreatedValue", { value: formatCreatedAt(session.created_at) })}</p>
<SessionFailureReason session={session} />
</div>
);
}
function SessionFailureReason({ session }: { session: KubernetesSession }) {
return session.failure_reason ? (
<p className="break-words text-xs text-destructive">{session.failure_reason}</p>
) : null;
}
function workspaceLabel(value?: string): string {
const keys: Record<string, string> = {
managed_pvc: "executors:kubernetesWorkspaceManagedPvc",
empty_dir: "executors:kubernetesWorkspaceEmptyDir",
existing_claim: "executors:kubernetesWorkspaceExistingClaim",
};
return value && keys[value] ? translationLabel(keys[value]) : value || "-";
}
function statusLabel(value: string): string {
const key = STATUS_LABEL_KEYS[value.toLowerCase()];
return key ? translationLabel(key, value) : value;
}
function translationLabel(key: string, fallback?: string): string {
return i18n.exists(key) ? translate(key) : (fallback ?? key);
}
function shortId(value: string): string {
return value.slice(0, 8);
}
function formatCreatedAt(value?: string): string {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : formatDateTime(date);
}
@@ -1,4 +1,4 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { KubernetesConnectionCard } from "./kubernetes-connection-card";
@@ -106,8 +106,29 @@ describe("Kubernetes settings cards", () => {
const card = screen.getByTestId("kubernetes-mobile-session-list");
expect(within(card).getByText("task-123456789")).toBeTruthy();
expect(within(card).getByText("session-987654321")).toBeTruthy();
expect(within(card).getByText("Task")).toBeTruthy();
expect(within(card).getByText("Session")).toBeTruthy();
expect(within(card).getByText(/^Task:/)).toBeTruthy();
expect(within(card).getByText(/^Session:/)).toBeTruthy();
});
it("shows retained requests, guidance, and task navigation on mobile", () => {
responsive.isMobile = true;
const state = sessionsState();
state.sessions[0] = {
...state.sessions[0],
session_state: "CANCELLED",
retention_state: "retained",
main_container_requests: { cpu: "0", memory: "512Mi" },
};
render(<KubernetesSessionsCard state={state} />);
const card = screen.getByTestId("kubernetes-mobile-session-list");
expect(within(card).getByText("Retained")).toBeTruthy();
expect(within(card).getByTestId("kubernetes-session-request-summary").textContent).toContain(
"0 CPU · 512Mi memory",
);
expect(within(card).getByRole("link").getAttribute("href")).toBe("/t/task-123456789");
expect(screen.getByText(/Stop preserves Kubernetes resources/)).toBeTruthy();
});
});
@@ -118,8 +139,8 @@ describe("Kubernetes session status cards", () => {
const row = screen.getByText("task-123").closest("tr");
expect(row).not.toBeNull();
const statusCell = within(row as HTMLTableRowElement).getByTestId("kubernetes-session-status");
expect(within(statusCell).getByText("pods is forbidden: RBAC denied")).toBeTruthy();
fireEvent.click(within(row as HTMLTableRowElement).getByLabelText("Show session details"));
expect(screen.getByText("pods is forbidden: RBAC denied")).toBeTruthy();
});
it("labels a successfully completed Pod as succeeded instead of terminated", () => {
@@ -134,8 +155,9 @@ describe("Kubernetes session status cards", () => {
render(<KubernetesSessionsCard state={state} />);
expect(screen.getByText("Succeeded")).toBeTruthy();
expect(screen.queryByText("Terminated")).toBeNull();
const status = screen.getByTestId("kubernetes-session-status-summary");
expect(within(status).getByText("Pod: Succeeded")).toBeTruthy();
expect(within(status).getByText("Container: Terminated")).toBeTruthy();
});
});
@@ -154,6 +176,9 @@ function sessionsState(): KubernetesSessionsState {
workspace_kind: "managed_pvc",
created_at: "2026-08-24T10:00:00Z",
failure_reason: "pods is forbidden: RBAC denied",
session_state: "RUNNING",
retention_state: "active",
main_container_requests: { cpu: "250m", memory: "1Gi" },
},
],
loading: false,
+61 -13
View File
@@ -50,6 +50,8 @@ export type BackendContext = {
frontendPort: number;
frontendUrl: string;
tmpDir: string;
/** Active structured backend log for assertions that need Info records. */
logPath: string;
/** Current backend PID, exposed for process-owned socket assertions. */
pid: () => number | undefined;
/**
@@ -178,6 +180,27 @@ type BackendFixtureLifecycle = {
removeTempRoot?: (tmpDir: string) => void;
};
type BackendProcess = ChildProcess & {
waitForLogFile?: () => Promise<void>;
};
type LogFileStream = Pick<NodeJS.EventEmitter, "once" | "on">;
export function observeLogFile(logFile: LogFileStream): () => Promise<void> {
let logFileError: Error | undefined;
const logFileClosed = new Promise<void>((resolve) => {
logFile.once("close", resolve);
});
logFile.on("error", (error: Error) => {
logFileError ??= error;
});
return async () => {
await logFileClosed;
if (logFileError) throw logFileError;
};
}
function removeOwnedTempRoot(tmpDir: string): void {
fs.rmSync(tmpDir, {
recursive: true,
@@ -189,18 +212,20 @@ function removeOwnedTempRoot(tmpDir: string): void {
export async function runOwnedBackendFixture<T>(
tmpDir: string,
run: (registerProcess: (proc: ChildProcess) => void) => Promise<T>,
run: (registerProcess: (proc: BackendProcess) => void) => Promise<T>,
lifecycle: BackendFixtureLifecycle = {},
): Promise<T> {
const stopProcess = lifecycle.stopProcess ?? killProcessGroup;
const removeTempRoot = lifecycle.removeTempRoot ?? removeOwnedTempRoot;
let backendProc: ChildProcess | undefined;
let backendProc: BackendProcess | undefined;
const backendProcesses: BackendProcess[] = [];
let result: T | undefined;
const failures: unknown[] = [];
try {
result = await run((proc) => {
backendProc = proc;
backendProcesses.push(proc);
});
} catch (error) {
failures.push(error);
@@ -214,6 +239,15 @@ export async function runOwnedBackendFixture<T>(
}
}
for (const proc of backendProcesses) {
if (!proc.waitForLogFile) continue;
try {
await proc.waitForLogFile();
} catch (error) {
failures.push(error);
}
}
try {
removeTempRoot(tmpDir);
} catch (error) {
@@ -236,31 +270,37 @@ function spawnBackendProcess(
env: Record<string, string>,
debug: boolean,
port: number,
): ChildProcess {
logPath: string,
): BackendProcess {
const proc = spawn(KANDEV_BIN, ["__backend"], {
env: env as unknown as NodeJS.ProcessEnv,
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
const logFile = debug ? fs.createWriteStream(`/tmp/e2e-backend-${port}.log`) : null;
proc.once("exit", () => {
logFile?.end();
});
const logFile = fs.createWriteStream(logPath, { flags: "a" });
const waitForLogFile = observeLogFile(logFile);
const closeLogFile = () => {
if (!logFile.writableEnded) logFile.end();
};
proc.once("close", closeLogFile);
proc.once("error", closeLogFile);
proc.stderr?.on("data", (chunk: Buffer) => {
logFile.write(chunk);
if (debug) {
process.stderr.write(`[backend:${port}] ${chunk.toString()}`);
logFile?.write(chunk);
}
});
proc.stdout?.on("data", (chunk: Buffer) => {
logFile.write(chunk);
if (debug) {
process.stderr.write(`[backend-log:${port}] ${chunk.toString()}`);
logFile?.write(chunk);
}
});
return proc;
return Object.assign(proc, {
waitForLogFile,
});
}
/**
@@ -278,6 +318,8 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), `kandev-e2e-${workerInfo.workerIndex}-`),
);
const processLogPath = path.join(tmpDir, "backend-process.log");
const backendLogPath = path.join(tmpDir, ".kandev", "logs", "backend-logs.log");
let backendProc: ChildProcess | undefined;
await runOwnedBackendFixture(tmpDir, async (registerProcess) => {
@@ -381,7 +423,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
KANDEV_WORKTREE_ENABLED: "true",
KANDEV_WORKTREE_BASEPATH: worktreeBase,
KANDEV_REPOCLONE_BASEPATH: repoCloneBase,
KANDEV_LOG_LEVEL: process.env.KANDEV_LOG_LEVEL ?? "warn",
KANDEV_LOG_LEVEL: process.env.KANDEV_LOG_LEVEL ?? "info",
AGENTCTL_INSTANCE_PORT_BASE: String(agentctlPortBase),
AGENTCTL_INSTANCE_PORT_MAX: String(agentctlPortMax),
// AGENTCTL_AUTO_APPROVE_PERMISSIONS=true and
@@ -411,7 +453,12 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
const scopedEnv = new BackendFixtureEnvOverrides();
// --- Spawn backend ---
backendProc = spawnBackendProcess(scopedEnv.apply(baselineEnv), debug, backendPort);
backendProc = spawnBackendProcess(
scopedEnv.apply(baselineEnv),
debug,
backendPort,
processLogPath,
);
registerProcess(backendProc);
// /ready (not /health) — /health flips green as soon as the listener
// is bound, before routes are wired; tests that immediately issue API
@@ -438,7 +485,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
// 2 s. TIME_WAIT can linger for 30–120 s under load; the probe exits
// as soon as the port stops accepting connections (typically <200 ms).
await waitForPortFree(backendPort);
backendProc = spawnBackendProcess(nextEnv, debug, backendPort);
backendProc = spawnBackendProcess(nextEnv, debug, backendPort, processLogPath);
registerProcess(backendProc);
// Pass the process so waitForHealth fails fast if it exits (e.g. port still in use).
// /ready, not /health — see the comment on the initial spawn above.
@@ -469,6 +516,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
frontendPort,
frontendUrl,
tmpDir,
logPath: backendLogPath,
pid: () => backendProc?.pid,
restart,
ensureReady,
+22 -1
View File
@@ -1,6 +1,10 @@
import { type Page, test as base } from "@playwright/test";
import { backendFixture, type BackendContext } from "./backend";
import { hasDocker } from "./docker-probe";
import {
buildKubernetesWorkerImages,
type KubernetesWorkerImages,
} from "./kubernetes-worker-images";
import { type KubernetesCluster, provisionKubernetesCluster } from "./kubernetes-tools";
import { ApiClient } from "../helpers/api-client";
import type { WorkflowStep } from "../../lib/types/http";
@@ -98,7 +102,12 @@ export async function seedKubernetesBackend(
export const kubernetesTest = backendFixture.extend<
{ testPage: Page; kubernetesCleanup: void },
{ apiClient: ApiClient; cluster: KubernetesCluster; seedData: KubernetesSeedData }
{
apiClient: ApiClient;
cluster: KubernetesCluster;
seedData: KubernetesSeedData;
workerImages: KubernetesWorkerImages;
}
>({
apiClient: [
async ({ backend }, use) => {
@@ -131,6 +140,18 @@ export const kubernetesTest = backendFixture.extend<
{ scope: "worker", timeout: 600_000 },
],
workerImages: [
async ({ cluster }, use) => {
const images = await buildKubernetesWorkerImages(cluster);
try {
await use(images);
} finally {
await images.dispose();
}
},
{ scope: "worker", timeout: 900_000 },
],
seedData: [
async ({ apiClient, cluster }, use) => {
const seed = await seedKubernetesBackend(apiClient, cluster, { label: "kubeconfig" });
+16
View File
@@ -450,6 +450,18 @@ function uniqueClusterName(workerIndex: number): string {
return `kandev-e2e-${process.pid}-${workerIndex}-${randomUUID().slice(0, 6)}`;
}
function kindClusterConfig(): string {
return `apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
kubeadmConfigPatches:
- |
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
featureGates:
KubeletInUserNamespace: true
`;
}
async function waitForPortForward(proc: ChildProcess, timeoutMs = 30_000): Promise<number> {
return new Promise<number>((resolve, reject) => {
const timer = setTimeout(() => {
@@ -683,6 +695,7 @@ export async function provisionKubernetesCluster(
const adminKubeconfig = path.join(root, `${name}.kubeconfig`);
const hostKubeconfig = path.join(root, `${name}.host.kubeconfig`);
const restrictedKubeconfig = path.join(root, `${name}.restricted.kubeconfig`);
const kindConfigPath = path.join(root, `${name}.kind.yaml`);
const image = `kandev-kubernetes-e2e:${name}`;
const marker = ownershipMarkerPath();
const ownership = new FixtureResourceOwnership();
@@ -732,6 +745,7 @@ export async function provisionKubernetesCluster(
}
assertRuntimeImageTagAvailable(image, dockerImageTagExists(image));
ownership.acquire("image", () => buildRuntimeImage(image));
fs.writeFileSync(kindConfigPath, kindClusterConfig(), { mode: 0o600 });
writeClusterOwnershipMarker(marker, name);
ownership.acquire("cluster", () =>
execFileSync(
@@ -741,6 +755,8 @@ export async function provisionKubernetesCluster(
"cluster",
"--name",
name,
"--config",
kindConfigPath,
"--image",
fixturePin.nodeImage,
"--kubeconfig",
@@ -0,0 +1,199 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type { KubernetesCluster } from "./kubernetes-tools";
const REPOSITORY_ROOT = path.resolve(__dirname, "../../../..");
const IMAGE_DIR = path.join(REPOSITORY_ROOT, "k8s", "worker-images");
const DOCKERFILE = path.join(IMAGE_DIR, "Dockerfile");
const PIN_FILE = path.join(IMAGE_DIR, "pins.env");
const MOCK_AGENT = path.join(REPOSITORY_ROOT, "apps/backend/bin/mock-agent-linux-amd64");
const TARGETS = ["minimal", "node-pnpm", "python"] as const;
const PRESET_FILES: Record<KubernetesWorkerTarget, string> = {
minimal: "minimal.yaml",
"node-pnpm": "node-pnpm.yaml",
python: "python.yaml",
};
export type KubernetesWorkerTarget = (typeof TARGETS)[number];
export type KubernetesWorkerImages = {
minimal: string;
nodePnpm: string;
python: string;
dispose: () => Promise<void>;
};
export function kubernetesWorkerPreset(
target: KubernetesWorkerTarget,
image: string,
imagePullPolicy: "Always" | "IfNotPresent" | "Never" = "Never",
): string {
const presetPath = path.join(REPOSITORY_ROOT, "k8s", "presets", PRESET_FILES[target]);
let template = fs.readFileSync(presetPath, "utf8");
template = replacePresetField(template, "image", image, presetPath);
return replacePresetField(template, "imagePullPolicy", imagePullPolicy, presetPath);
}
function replacePresetField(
template: string,
field: string,
value: string,
presetPath: string,
): string {
const pattern = new RegExp(`^(\\s+${field}:\\s+).+$`, "gm");
const matches = template.match(pattern) ?? [];
if (matches.length !== 1) {
throw new Error(`worker preset must contain exactly one ${field} field: ${presetPath}`);
}
return template.replace(pattern, (_match: string, prefix: string) => `${prefix}${value}`);
}
type BuiltImage = {
tag: string;
target: KubernetesWorkerTarget;
kind: "recipe" | "harness";
};
export async function buildKubernetesWorkerImages(
cluster: KubernetesCluster,
): Promise<KubernetesWorkerImages> {
const pins = readPins();
const suffix = `${cluster.name}-${process.pid}-${randomUUID().slice(0, 8)}`;
const built: BuiltImage[] = [];
try {
for (const target of TARGETS) {
const recipeTag = `kandev-worker-e2e:${suffix}-${target}`;
const harnessTag = `kandev-worker-e2e:${suffix}-${target}-harness`;
built.push(
{ tag: recipeTag, target, kind: "recipe" },
{ tag: harnessTag, target, kind: "harness" },
);
buildImage(target, recipeTag, pins.baseImage, pins.pnpmVersion);
loadImage(cluster, recipeTag);
buildHarnessImage(recipeTag, harnessTag);
loadImage(cluster, harnessTag);
}
} catch (error) {
await disposeImages(built);
throw error;
}
let disposed = false;
return {
minimal: imageFor(built, "minimal"),
nodePnpm: imageFor(built, "node-pnpm"),
python: imageFor(built, "python"),
dispose: async () => {
if (disposed) return;
disposed = true;
await disposeImages(built);
},
};
}
function readPins(): { baseImage: string; pnpmVersion: string } {
const values = new Map<string, string>();
for (const line of fs.readFileSync(PIN_FILE, "utf8").split(/\r?\n/)) {
const match = line.match(/^([A-Z_]+)=(.+)$/);
if (match) values.set(match[1]!, match[2]!);
}
const baseImage = values.get("BASE_IMAGE");
const pnpmVersion = values.get("PNPM_VERSION");
if (!baseImage || !/@sha256:[0-9a-f]{64}$/.test(baseImage)) {
throw new Error(`worker image pins must contain an immutable BASE_IMAGE: ${PIN_FILE}`);
}
if (!pnpmVersion || !/^\d+\.\d+\.\d+$/.test(pnpmVersion)) {
throw new Error(`worker image pins must contain a semantic PNPM_VERSION: ${PIN_FILE}`);
}
return { baseImage, pnpmVersion };
}
function buildImage(
target: KubernetesWorkerTarget,
tag: string,
baseImage: string,
pnpmVersion: string,
): void {
execFileSync(
"docker",
[
"build",
"--platform",
"linux/amd64",
"--build-arg",
`BASE_IMAGE=${baseImage}`,
"--build-arg",
`PNPM_VERSION=${pnpmVersion}`,
"--target",
target,
"--tag",
tag,
"--file",
DOCKERFILE,
REPOSITORY_ROOT,
],
{
timeout: 600_000,
stdio: process.env.E2E_DEBUG ? "inherit" : ["ignore", "ignore", "inherit"],
},
);
}
function buildHarnessImage(baseTag: string, tag: string): void {
if (!fs.existsSync(MOCK_AGENT)) {
throw new Error(`Kubernetes E2E mock-agent binary is missing: ${MOCK_AGENT}`);
}
const context = fs.mkdtempSync(path.join(path.dirname(MOCK_AGENT), "kubernetes-worker-harness-"));
const dockerfile = path.join(context, "Dockerfile");
fs.copyFileSync(MOCK_AGENT, path.join(context, "mock-agent-linux-amd64"));
fs.writeFileSync(
dockerfile,
`FROM ${baseTag}
USER root
COPY mock-agent-linux-amd64 /usr/local/bin/mock-agent
RUN chmod 0755 /usr/local/bin/mock-agent
USER 1000
`,
);
try {
execFileSync(
"docker",
["build", "--platform", "linux/amd64", "--tag", tag, "--file", dockerfile, context],
{
timeout: 600_000,
stdio: process.env.E2E_DEBUG ? "inherit" : ["ignore", "ignore", "inherit"],
},
);
} finally {
fs.rmSync(context, { recursive: true, force: true });
}
}
function loadImage(cluster: KubernetesCluster, tag: string): void {
execFileSync(cluster.kindBin, ["load", "docker-image", tag, "--name", cluster.name], {
timeout: 300_000,
stdio: process.env.E2E_DEBUG ? "inherit" : ["ignore", "ignore", "inherit"],
});
}
function imageFor(built: BuiltImage[], target: KubernetesWorkerTarget): string {
const image = built.find((entry) => entry.target === target && entry.kind === "harness")?.tag;
if (!image) throw new Error(`worker image target was not built: ${target}`);
return image;
}
async function disposeImages(images: BuiltImage[]): Promise<void> {
for (const { tag } of images) {
try {
execFileSync("docker", ["image", "rm", "--force", tag], {
timeout: 60_000,
stdio: "ignore",
});
} catch {
// The exact tag may already be gone after a failed build or Kind load.
}
}
}
@@ -1,3 +1,4 @@
import fs from "node:fs";
import type { Page } from "@playwright/test";
import {
kubernetesExecutorConfig,
@@ -48,6 +49,16 @@ function launchTask(
});
}
function logFieldEquals(line: string, field: string, value: string): boolean {
return (
line.includes(`${field}=${value}`) ||
line.includes(`${field}: ${value}`) ||
line.includes(`${field}:${value}`) ||
line.includes(`"${field}":"${value}"`) ||
line.includes(`"${field}": "${value}"`)
);
}
async function assertUserVisibleFailure(page: Page, taskId: string, expected: RegExp) {
await page.goto(`/t/${taskId}`);
const session = new SessionPage(page);
@@ -138,6 +149,27 @@ test("launches through kubeconfig with Pod exec and a loopback-only agentctl for
const task = await launchTask(apiClient, seedData, "Kubernetes kubeconfig launch");
expect(task.session_id).toBeTruthy();
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for Kubernetes kubeconfig launch");
await expect
.poll(() => fs.readFileSync(backend.logPath, "utf8"), {
timeout: 30_000,
message: "Waiting for Kubernetes launch timing records",
})
.toContain("kubernetes.launch.completed");
const launchLog = fs.readFileSync(backend.logPath, "utf8");
const correlatedLaunchLines = launchLog
.split("\n")
.filter((line) => line.includes(task.id) && line.includes(task.session_id!));
expect(
correlatedLaunchLines.filter((line) => line.includes("kubernetes.launch.completed")),
).toHaveLength(1);
for (const stage of ["storage", "pod_ready", "bootstrap", "agentctl_connect"]) {
expect(
correlatedLaunchLines.filter(
(line) => line.includes("kubernetes.launch.stage") && logFieldEquals(line, "stage", stage),
),
`expected one ${stage} timing record for ${task.id}`,
).toHaveLength(1);
}
const pod = await waitForKubernetesPod(cluster, task.id, task.session_id!);
const labels = pod.metadata.labels ?? {};
expect(labels["app.kubernetes.io/managed-by"]).toBe("kandev");
@@ -369,13 +401,39 @@ test("preserves a managed PVC across ordinary stop/resume and deletes it termina
"printf retained > /workspace/kandev-retained",
]);
await waitForAgentMessage(apiClient, task.session_id!, "started");
expect(
(await apiClient.listKubernetesSessions(seedData.executorId)).find(
(row) => row.task_id === task.id,
),
).toMatchObject({
session_id: task.session_id,
retention_state: "active",
});
await apiClient.stopSession({ session_id: task.session_id! });
await waitForTaskSessionState(apiClient, task.id, task.session_id!, "CANCELLED");
await expect
.poll(
async () =>
(await apiClient.listKubernetesSessions(seedData.executorId)).find(
(row) => row.task_id === task.id,
),
{ timeout: 60_000, message: "Waiting for retained Kubernetes session status" },
)
.toMatchObject({ session_state: "CANCELLED", retention_state: "retained" });
const resumed = await apiClient.launchSession(
{ task_id: task.id, intent: "resume", session_id: task.session_id! },
90_000,
);
await expect
.poll(
async () =>
(await apiClient.listKubernetesSessions(seedData.executorId)).find(
(row) => row.task_id === task.id,
),
{ timeout: 90_000, message: "Waiting for active resumed Kubernetes session status" },
)
.toMatchObject({ session_state: "WAITING_FOR_INPUT", retention_state: "active" });
expect(resumed.session_id).toBe(task.session_id);
await waitForTaskSessionState(
apiClient,
@@ -0,0 +1,192 @@
import {
expect,
kubernetesExecutorConfig,
kubernetesProfileConfig,
test,
} from "../../fixtures/kubernetes-test-base";
import {
kubernetesWorkerPreset,
type KubernetesWorkerTarget,
} from "../../fixtures/kubernetes-worker-images";
import {
execInKubernetesPod,
waitForKubernetesPod,
waitForKubernetesPVC,
waitForKubernetesResourceAbsent,
waitForTaskSessionState,
} from "../../helpers/kubernetes";
import { waitForAgentMessage } from "../../helpers/session";
const WORKER_TARGETS: Array<{
target: KubernetesWorkerTarget;
imageKey: "minimal" | "nodePnpm" | "python";
}> = [
{ target: "minimal", imageKey: "minimal" },
{ target: "node-pnpm", imageKey: "nodePnpm" },
{ target: "python", imageKey: "python" },
];
test("runs every prepared worker image through the Kubernetes lifecycle", async ({
apiClient,
cluster,
seedData,
workerImages,
}) => {
test.setTimeout(1_200_000);
for (const { target, imageKey } of WORKER_TARGETS) {
await test.step(target, async () => {
const profileConfig = kubernetesProfileConfig(cluster, {
pod_template_yaml: kubernetesWorkerPreset(target, workerImages[imageKey]),
"workspace.mode": "managed_pvc",
"workspace.size": "1Gi",
"workspace.access_modes": JSON.stringify(["ReadWriteOnce"]),
});
const diagnostic = await apiClient.testKubernetesConnection({
config: kubernetesExecutorConfig(cluster),
profile_config: profileConfig,
});
expect(diagnostic.success, JSON.stringify(diagnostic)).toBe(true);
const profile = await apiClient.createExecutorProfile(seedData.executorId, {
name: "E2E worker preset " + target,
config: profileConfig,
prepare_script: "",
cleanup_script: "",
env_vars: [],
});
let task: { id: string; session_id?: string } | undefined;
let archived = false;
try {
task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Kubernetes worker preset " + target,
seedData.agentProfileId,
{
description: 'e2e:message("started")\ne2e:delay(60000)',
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
executor_id: seedData.executorId,
executor_profile_id: profile.id,
},
);
expect(task.session_id).toBeTruthy();
const pod = await waitForKubernetesPod(cluster, task.id, task.session_id!);
const claim = await waitForKubernetesPVC(cluster, task.id, task.session_id!);
expect(execInKubernetesPod(cluster, pod.metadata.name, ["id", "-u"])).toBe("1000");
const smokeCommands = [
"mkdir -p /workspace/" + target + "-git",
"git -C /workspace/" + target + "-git init",
"git -C /workspace/" + target + "-git config user.email smoke@example.invalid",
"git -C /workspace/" + target + "-git config user.name kandev-smoke",
"printf '%s\\n' '" + target + "' > /workspace/" + target + "-git/README.md",
"git -C /workspace/" + target + "-git add README.md",
"git -C /workspace/" + target + "-git commit -m smoke",
"printf retained > /workspace/kandev-worker-" + target + "-retained",
].join("\n");
execInKubernetesPod(cluster, pod.metadata.name, ["/bin/sh", "-ceu", smokeCommands]);
await waitForAgentMessage(apiClient, task.session_id!, "started");
if (target === "node-pnpm") {
expect(execInKubernetesPod(cluster, pod.metadata.name, ["pnpm", "--version"])).toMatch(
/^9\.15\.9/,
);
expect(execInKubernetesPod(cluster, pod.metadata.name, ["node", "--version"])).toMatch(
/^v\d+\./,
);
const nodePnpmSmoke = [
"set -eu",
"package=/workspace/kandev-npm-global-smoke",
'mkdir -p "$package" /workspace/.npm-global /workspace/.pnpm /workspace/.pnpm-store',
'printf \'%s\\n\' \'{"name":"kandev-npm-global-smoke","version":"1.0.0","bin":{"kandev-npm-global-smoke":"cli.js"}}\' > "$package/package.json"',
"printf '%s\\n' '#!/bin/sh' 'printf npm-global-ok' > \"$package/cli.js\"",
'chmod +x "$package/cli.js"',
'npm install --global --offline --ignore-scripts "$package" >/dev/null',
"test -x /workspace/.npm-global/bin/kandev-npm-global-smoke",
'test "$(/workspace/.npm-global/bin/kandev-npm-global-smoke)" = npm-global-ok',
"test -w /workspace/.pnpm",
"test -w /workspace/.pnpm-store",
"touch /workspace/.pnpm/.write-test /workspace/.pnpm-store/.write-test",
'rm -rf "$package" /workspace/.npm-global/lib/node_modules/kandev-npm-global-smoke /workspace/.npm-global/bin/kandev-npm-global-smoke /workspace/.pnpm/.write-test /workspace/.pnpm-store/.write-test',
].join(" && ");
expect(
execInKubernetesPod(cluster, pod.metadata.name, ["/bin/sh", "-ceu", nodePnpmSmoke]),
).toBe("");
} else if (target === "python") {
const pythonSmoke = [
"python3 -m venv /workspace/kandev-python-venv",
"/workspace/kandev-python-venv/bin/python -c 'import sys; assert sys.version_info.major == 3'",
].join(" && ");
expect(
execInKubernetesPod(cluster, pod.metadata.name, ["/bin/sh", "-ceu", pythonSmoke]),
).toBe("");
} else {
expect(execInKubernetesPod(cluster, pod.metadata.name, ["git", "--version"])).toMatch(
/^git version /,
);
}
expect(
(await apiClient.listKubernetesSessions(seedData.executorId)).find(
(row) => row.task_id === task!.id,
),
).toMatchObject({ retention_state: "active" });
await apiClient.stopSession({ session_id: task.session_id! });
await waitForTaskSessionState(apiClient, task.id, task.session_id!, "CANCELLED");
await expect
.poll(
async () =>
(await apiClient.listKubernetesSessions(seedData.executorId)).find(
(row) => row.task_id === task!.id,
),
{ timeout: 60_000, message: "Waiting for " + target + " retention projection" },
)
.toMatchObject({ session_state: "CANCELLED", retention_state: "retained" });
const resumed = await apiClient.launchSession(
{ task_id: task.id, intent: "resume", session_id: task.session_id! },
90_000,
);
expect(resumed.session_id).toBe(task.session_id);
await waitForTaskSessionState(
apiClient,
task.id,
task.session_id!,
"WAITING_FOR_INPUT",
90_000,
);
const after = await waitForKubernetesPod(cluster, task.id, task.session_id!);
const afterClaim = await waitForKubernetesPVC(cluster, task.id, task.session_id!);
expect(after.metadata.uid).toBe(pod.metadata.uid);
expect(afterClaim.metadata.uid).toBe(claim.metadata.uid);
expect(
execInKubernetesPod(cluster, after.metadata.name, [
"cat",
"/workspace/kandev-worker-" + target + "-retained",
]),
).toBe("retained");
await expect
.poll(
async () =>
(await apiClient.listKubernetesSessions(seedData.executorId)).find(
(row) => row.task_id === task!.id,
),
{ timeout: 90_000, message: "Waiting for " + target + " active projection" },
)
.toMatchObject({ session_state: "WAITING_FOR_INPUT", retention_state: "active" });
await apiClient.archiveTask(task.id);
archived = true;
await waitForKubernetesResourceAbsent(cluster, "pod", pod.metadata.name);
await waitForKubernetesResourceAbsent(
cluster,
"persistentvolumeclaim",
claim.metadata.name,
);
} finally {
if (task && !archived) await apiClient.archiveTask(task.id).catch(() => undefined);
await apiClient.deleteExecutorProfile(profile.id).catch(() => undefined);
}
});
}
});
@@ -3,6 +3,7 @@ import { devices, type Browser, type Page } from "@playwright/test";
import { backendFixture as test, type BackendContext } from "../../fixtures/backend";
import { ApiClient } from "../../helpers/api-client";
import { acceptInvite, createInviteToken, setupAdmin } from "../../helpers/auth";
import { PrAssetCapture } from "../../helpers/pr-asset-capture";
import { expect } from "@playwright/test";
const ADMIN = { email: "k8s-admin@e2e.dev", password: "adminpass123", displayName: "K8s Admin" };
@@ -315,3 +316,109 @@ test("profile-less Kubernetes executors retain the standalone recovery page", as
await context.close();
}
});
test("desktop session table shows retained state, requests, guidance, and task navigation", async ({
browser,
backend,
}) => {
const apiClient = new ApiClient(backend.baseUrl);
const executor = await apiClient.createExecutor("Desktop session projection", "k8s", {
auth_mode: "kubeconfig",
kubeconfig_path: "/tmp/desktop-sessions.kubeconfig",
namespace: "default",
request_timeout_seconds: "30",
});
const profile = await apiClient.createExecutorProfile(executor.id, {
name: "Desktop session profile",
config: {
platform: "linux/amd64",
main_container: "kandev-agent",
pod_template_yaml: POD_TEMPLATE,
"workspace.mode": "empty_dir",
},
prepare_script: "",
cleanup_script: "",
env_vars: [],
});
const taskId = "task-desktop-123456789";
const sessionId = "session-desktop-987654321";
const { context, page } = await openContext(browser, backend);
const prCapture = new PrAssetCapture(page, path.join(__dirname, "kubernetes-executor.spec.ts"));
await page.route("**/api/v1/kubernetes/executors/" + executor.id + "/sessions", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
task_id: taskId,
session_id: sessionId,
pod_name: "kandev-desktop-session",
pod_phase: "Running",
container_state: "running",
restarts: 0,
workspace_kind: "empty_dir",
created_at: "2026-08-24T10:00:00Z",
session_state: "CANCELLED",
retention_state: "retained",
main_container_requests: { cpu: "0", memory: "512Mi" },
},
]),
});
});
try {
await page.goto("/settings/executors/" + profile.id);
const table = page.getByTestId("kubernetes-sessions-table");
await expect(table).toContainText(taskId.slice(0, 8));
await expect(table).toContainText("Retained");
await expect(table).toContainText("0 CPU");
await expect(table).toContainText("512Mi memory");
const sessionRow = table.getByTestId("kubernetes-session-row");
await expect(sessionRow).toHaveCount(1);
const rowBox = await sessionRow.boundingBox();
expect(rowBox).not.toBeNull();
expect(rowBox!.height).toBeLessThanOrEqual(96);
const statusSummary = sessionRow.getByTestId("kubernetes-session-status-summary");
await expect(statusSummary).toContainText("Cancelled");
await expect(statusSummary).toContainText("Pod: Running");
await expect(statusSummary).toContainText("Container: Running");
const tableContainer = table.locator("..");
const createdCell = sessionRow.locator("td").last();
const [tableContainerBox, createdCellBox] = await Promise.all([
tableContainer.boundingBox(),
createdCell.boundingBox(),
]);
expect(tableContainerBox).not.toBeNull();
expect(createdCellBox).not.toBeNull();
expect(createdCellBox!.x + createdCellBox!.width).toBeLessThanOrEqual(
tableContainerBox!.x + tableContainerBox!.width,
);
await expect(table.getByTestId("kubernetes-session-runtime-details")).toHaveCount(0);
await sessionRow.getByRole("button", { name: "Show session details" }).click();
const runtimeDetails = table.getByTestId("kubernetes-session-runtime-details");
await expect(runtimeDetails).toContainText("Session state: Cancelled");
await expect(runtimeDetails).toContainText("Resource retention: Retained");
await expect(runtimeDetails).toContainText("Pod state: Running");
await expect(runtimeDetails).toContainText("Main-container state: Running");
await sessionRow.getByRole("button", { name: "Hide session details" }).click();
await expect(table.getByTestId("kubernetes-session-runtime-details")).toHaveCount(0);
await expect(page.getByTestId("kubernetes-session-guidance")).toContainText(
"Stop preserves Kubernetes resources",
);
await expect(page.getByTestId("kubernetes-session-guidance")).toContainText(
"Existing claims are not deleted by Kandev",
);
const taskLink = table.getByTestId("kubernetes-session-task-link");
await expect(taskLink).toHaveAttribute("href", "/t/" + taskId);
await table.scrollIntoViewIfNeeded();
await prCapture.screenshot("retained-kubernetes-session-desktop", {
caption: "Desktop retained Kubernetes session status",
page,
});
await taskLink.click();
await expect(page).toHaveURL("/t/" + taskId);
} finally {
prCapture.flush();
await apiClient.deleteExecutor(executor.id).catch(() => undefined);
await context.close();
}
});
@@ -4,6 +4,7 @@ import { backendFixture as test, type BackendContext } from "../../fixtures/back
import { ApiClient } from "../../helpers/api-client";
import { acceptInvite, createInviteToken, setupAdmin } from "../../helpers/auth";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { PrAssetCapture } from "../../helpers/pr-asset-capture";
import { expect } from "@playwright/test";
const ADMIN = {
@@ -352,6 +353,10 @@ test("active session cards expose task and session identities without a cluster"
const taskId = "task-mobile-123456789";
const sessionId = "session-mobile-987654321";
const { context, page } = await openMobileContext(browser, backend);
const prCapture = new PrAssetCapture(
page,
path.join(__dirname, "mobile-kubernetes-executor.spec.ts"),
);
await page.route(`**/api/v1/kubernetes/executors/${executor.id}/sessions`, async (route) => {
await route.fulfill({
status: 200,
@@ -366,6 +371,9 @@ test("active session cards expose task and session identities without a cluster"
restarts: 0,
workspace_kind: "empty_dir",
created_at: "2026-08-24T10:00:00Z",
session_state: "CANCELLED",
retention_state: "retained",
main_container_requests: { cpu: "0", memory: "512Mi" },
},
]),
});
@@ -376,8 +384,34 @@ test("active session cards expose task and session identities without a cluster"
const sessions = page.getByTestId("kubernetes-mobile-session-list");
await expect(sessions).toContainText(taskId);
await expect(sessions).toContainText(sessionId);
await expect(sessions).toContainText("Retained");
await expect(sessions).toContainText("0 CPU");
await expect(sessions).toContainText("512Mi memory");
const statusSummary = sessions.getByTestId("kubernetes-session-status-summary");
await expect(statusSummary).toContainText("Cancelled");
await expect(statusSummary).toContainText("Pod: Running");
await expect(statusSummary).toContainText("Container: Running");
const statusBox = await statusSummary.boundingBox();
expect(statusBox).not.toBeNull();
expect(statusBox!.height).toBeLessThanOrEqual(80);
await expect(page.getByTestId("kubernetes-session-guidance")).toContainText(
"Stop preserves Kubernetes resources",
);
await expect(page.getByTestId("kubernetes-session-guidance")).toContainText(
"Existing claims are not deleted by Kandev",
);
const taskLink = sessions.getByTestId("kubernetes-session-task-link");
await expect(taskLink).toHaveAttribute("href", "/t/" + taskId);
await assertNoDocumentHorizontalOverflow(page, "mobile Kubernetes active sessions");
await sessions.scrollIntoViewIfNeeded();
await prCapture.screenshot("retained-kubernetes-session-mobile", {
caption: "Mobile retained Kubernetes session status",
page,
});
await taskLink.tap();
await expect(page).toHaveURL("/t/" + taskId);
} finally {
prCapture.flush();
await apiClient.deleteExecutor(executor.id).catch(() => undefined);
await context.close();
}
@@ -6,7 +6,7 @@ import os from "node:os";
import path from "node:path";
import { test } from "@playwright/test";
import { runOwnedBackendFixture, waitForHealth } from "../../fixtures/backend";
import { observeLogFile, runOwnedBackendFixture, waitForHealth } from "../../fixtures/backend";
import { stopSSHServer, type SSHServerHandle } from "../../helpers/ssh";
test.describe("backend fixture lifecycle", () => {
@@ -68,7 +68,11 @@ test.describe("backend fixture lifecycle", () => {
test("stops the registered process before cleanup after a health failure", async () => {
const events: string[] = [];
const child = new EventEmitter() as unknown as ChildProcess;
const child = Object.assign(new EventEmitter(), {
waitForLogFile: async () => {
events.push("logs");
},
}) as unknown as ChildProcess;
await assert.rejects(
runOwnedBackendFixture(
@@ -92,7 +96,44 @@ test.describe("backend fixture lifecycle", () => {
/health failed/,
);
assert.deepEqual(events, ["run", "stop", "remove"]);
assert.deepEqual(events, ["run", "stop", "logs", "remove"]);
});
test("holds an early log error until the stream closes before removing the root", async () => {
const events: string[] = [];
const stream = new EventEmitter();
const logError = new Error("log stream failed");
const waitForLogFile = observeLogFile(stream);
stream.once("close", () => events.push("close"));
const child = Object.assign(new EventEmitter(), { waitForLogFile }) as unknown as ChildProcess;
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason);
process.on("unhandledRejection", onUnhandledRejection);
try {
await assert.rejects(
runOwnedBackendFixture(
"/tmp/exact-owned-root",
async (registerProcess) => {
registerProcess(child);
stream.emit("error", logError);
},
{
stopProcess: async () => {
events.push("stop");
stream.emit("close");
},
removeTempRoot: () => events.push("remove"),
},
),
(error) => error === logError,
);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(unhandledRejections, []);
assert.deepEqual(events, ["stop", "close", "remove"]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
test("surfaces cleanup failure alongside the fixture failure", async () => {
@@ -194,6 +194,21 @@ describe("Kubernetes diagnostic and session hooks", () => {
await expect(result.current.refresh()).resolves.toEqual(rows);
});
it("clears stale session rows when a refresh fails", async () => {
const rows = [{ session_id: "session-1", task_id: "task-1", restarts: 0 }];
const failure = new Error("status unavailable");
listKubernetesSessions.mockResolvedValueOnce(rows).mockRejectedValueOnce(failure);
const { result } = renderHook(() => useKubernetesSessions(EXECUTOR_ID));
await waitFor(() => expect(result.current.sessions).toEqual(rows));
await act(async () => {
await expect(result.current.refresh()).rejects.toBe(failure);
});
expect(result.current.sessions).toEqual([]);
expect(result.current.error).toBe(failure);
});
it("does not request Kubernetes sessions when the hook is disabled", async () => {
const { result } = renderHook(() => useKubernetesSessions("docker-1", false));
@@ -81,7 +81,10 @@ export function useKubernetesSessions(executorId: string, enabled = true) {
if (generation.current === current) setSessions(rows);
return rows;
} catch (cause) {
if (generation.current === current) setError(cause);
if (generation.current === current) {
setSessions([]);
setError(cause);
}
throw cause;
} finally {
if (generation.current === current) setLoading(false);
@@ -129,6 +129,29 @@ describe("Kubernetes session API", () => {
).toEqual([{ session_id: "session-1", task_id: "task-1", pod_name: "pod-1", restarts: 2 }]);
});
it("normalizes retention state and main-container requests", () => {
expect(
normalizeKubernetesSessions([
{
session_id: "session-1",
task_id: "task-1",
session_state: "CANCELLED",
retention_state: "retained",
main_container_requests: { cpu: "0", memory: "512Mi", extra: "ignored" },
},
]),
).toEqual([
{
session_id: "session-1",
task_id: "task-1",
restarts: 0,
session_state: "CANCELLED",
retention_state: "retained",
main_container_requests: { cpu: "0", memory: "512Mi" },
},
]);
});
it("GETs encoded executor session status and normalizes rows", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse([{ session_id: "session-1", task_id: "task-1", restarts: null }]),
+12 -1
View File
@@ -113,6 +113,13 @@ function normalizeSession(value: unknown): KubernetesSession[] {
restarts: finiteNumber(record.restarts),
};
for (const key of SESSION_STRING_KEYS) assignOptionalString(session, key, record[key]);
const requests = asRecord(record.main_container_requests);
if (requests) {
const normalized = {} as NonNullable<KubernetesSession["main_container_requests"]>;
assignOptionalString(normalized, "cpu", requests.cpu);
assignOptionalString(normalized, "memory", requests.memory);
if (normalized.cpu || normalized.memory) session.main_container_requests = normalized;
}
return [session];
}
@@ -123,10 +130,14 @@ const SESSION_STRING_KEYS = [
"workspace_kind",
"created_at",
"failure_reason",
"session_state",
"retention_state",
] as const;
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function finiteNumber(value: unknown): number {
+8
View File
@@ -35,6 +35,14 @@ export type KubernetesSession = {
workspace_kind?: string;
created_at?: string;
failure_reason?: string;
session_state?: string;
retention_state?: string;
main_container_requests?: KubernetesResourceRequests;
};
export type KubernetesResourceRequests = {
cpu?: string;
memory?: string;
};
export type KubernetesSessionImpact = {
+30
View File
@@ -209,6 +209,7 @@
"kubernetesAccessModesHelp": "Select the access modes requested for each managed claim.",
"kubernetesActiveSessions": "Active sessions",
"kubernetesActiveSessionsDescription": "Sanitized Pod and workspace status for sessions using this executor. Refreshes every 90 seconds.",
"kubernetesActiveSessionsGuidance": "Stop preserves Kubernetes resources. Archive or Delete can remove a managed workspace. Existing claims are not deleted by Kandev. Requests show configured main-container CPU and memory, not actual use.",
"kubernetesAdminSaveOnly": "Only an administrator can save Kubernetes settings.",
"kubernetesAdminTestOnly": "Only an administrator can run this Kubernetes test.",
"kubernetesAuthInCluster": "In-cluster service account",
@@ -251,6 +252,7 @@
"kubernetesKubeconfigPathHelp": "Absolute path on the Kandev backend host. Kubeconfig contents are never stored here.",
"kubernetesKubeconfigPathRequired": "An absolute kubeconfig path is required.",
"kubernetesMainContainer": "Main container",
"kubernetesContainerCompactValue": "Container: {{value}}",
"kubernetesMainContainerHelp": "Container that receives the Kandev bootstrap and runs agentctl.",
"kubernetesMainContainerRequired": "Main container name is required.",
"kubernetesMainContainerInvalid": "Main container must be a valid DNS label.",
@@ -258,9 +260,12 @@
"kubernetesNamespaceHelp": "Every Pod and managed claim for this executor stays in this namespace.",
"kubernetesNamespaceRequired": "A namespace is required.",
"kubernetesNoActiveSessions": "No active Kubernetes sessions.",
"kubernetesMainContainerRequests": "Main-container requests",
"kubernetesOpenTask": "Open task {{task}}, session {{session}}",
"kubernetesPlatform": "Platform",
"kubernetesPlatformHelp": "Adds the matching Kubernetes architecture selector to every Pod.",
"kubernetesPod": "Pod",
"kubernetesPodCompactValue": "Pod: {{value}}",
"kubernetesPodTemplate": "Raw PodTemplate YAML",
"kubernetesPodTemplateHelp": "Exactly one v1 PodTemplate document, up to 256 KiB. Unknown or conflicting fields are rejected. The starter uses {{image}}, which includes baseline tools and agent dependencies. Pin a release tag or digest for production.",
"kubernetesPodTemplateRequired": "PodTemplate YAML is required.",
@@ -273,7 +278,30 @@
"kubernetesRequestTimeoutHelp": "Timeout for Kubernetes API requests, from 1 to 300 seconds.",
"kubernetesRestarts_one": "Restart: {{count}}",
"kubernetesRestarts_other": "Restarts: {{count}}",
"kubernetesRequestCpu": "{{value}} CPU",
"kubernetesRequestMemory": "{{value}} memory",
"kubernetesRequests": "Requests",
"kubernetesRequestsUnspecified": "Main-container requests: Unspecified",
"kubernetesRequestsUnspecifiedValue": "Unspecified",
"kubernetesRetentionActive": "Active",
"kubernetesRetentionMissing": "Missing",
"kubernetesRetentionRetained": "Retained",
"kubernetesRetentionStateValue": "Resource retention: {{value}}",
"kubernetesRetentionTerminal": "Terminal",
"kubernetesRetentionTerminating": "Terminating",
"kubernetesRetentionUnknown": "Unknown",
"kubernetesSessionsFailed": "Failed to load Kubernetes sessions.",
"kubernetesSessionCancelled": "Cancelled",
"kubernetesSessionCompleted": "Completed",
"kubernetesSessionCreated": "Created",
"kubernetesSessionFailed": "Failed",
"kubernetesSessionIdle": "Idle",
"kubernetesHideSessionDetails": "Hide session details",
"kubernetesSessionRunning": "Running",
"kubernetesShowSessionDetails": "Show session details",
"kubernetesSessionStarting": "Starting",
"kubernetesSessionStateValue": "Session state: {{value}}",
"kubernetesSessionWaitingForInput": "Waiting for input",
"kubernetesStatusFailed": "Failed",
"kubernetesStatusPending": "Pending",
"kubernetesStatusRunning": "Running",
@@ -281,6 +309,8 @@
"kubernetesStatusTerminated": "Terminated",
"kubernetesStatusUnknown": "Unknown",
"kubernetesStatusWaiting": "Waiting",
"kubernetesPodStateValue": "Pod state: {{value}}",
"kubernetesContainerStateValue": "Main-container state: {{value}}",
"kubernetesStepConfiguration": "Configuration",
"kubernetesStepDiscovery": "API discovery",
"kubernetesStepExistingClaim": "Existing claim",
@@ -209,6 +209,7 @@
"kubernetesAccessModesHelp": "Śēĺēćţ ţĥē àććēśś ḿōďēś ŕēqũēśţēď ƒōŕ ēàćĥ ḿàńàĝēď ćĺàĩḿ.",
"kubernetesActiveSessions": "Àćţĩvē śēśśĩōńś",
"kubernetesActiveSessionsDescription": "Śàńĩţĩźēď Ƥōď àńď ŵōŕķśƥàćē śţàţũś ƒōŕ śēśśĩōńś ũśĩńĝ ţĥĩś ēxēćũţōŕ. Ŕēƒŕēśĥēś ēvēŕŷ 90 śēćōńďś.",
"kubernetesActiveSessionsGuidance": "Śţōƥ ƥŕēśēŕvēś Ķũƀēŕńēţēś ŕēśōũŕćēś. Àŕćĥĩvē ōŕ Ďēĺēţē ćàń ŕēḿōvē à ḿàńàĝēď ŵōŕķśƥàćē. Ēxĩśţĩńĝ ćĺàĩḿś àŕē ńōţ ďēĺēţēď ƀŷ Ķàńďēv. Ŕēqũēśţś śĥōŵ ćōńƒĩĝũŕēď ḿàĩń-ćōńţàĩńēŕ ĆƤŨ àńď ḿēḿōŕŷ, ńōţ àćţũàĺ ũśē.",
"kubernetesAdminSaveOnly": "Ōńĺŷ àń àďḿĩńĩśţŕàţōŕ ćàń śàvē Ķũƀēŕńēţēś śēţţĩńĝś.",
"kubernetesAdminTestOnly": "Ōńĺŷ àń àďḿĩńĩśţŕàţōŕ ćàń ŕũń ţĥĩś Ķũƀēŕńēţēś ţēśţ.",
"kubernetesAuthInCluster": "Ĩń-ćĺũśţēŕ śēŕvĩćē àććōũńţ",
@@ -251,6 +252,7 @@
"kubernetesKubeconfigPathHelp": "Àƀśōĺũţē ƥàţĥ ōń ţĥē Ķàńďēv ƀàćķēńď ĥōśţ. Ķũƀēćōńƒĩĝ ćōńţēńţś àŕē ńēvēŕ śţōŕēď ĥēŕē.",
"kubernetesKubeconfigPathRequired": "Àń àƀśōĺũţē ķũƀēćōńƒĩĝ ƥàţĥ ĩś ŕēqũĩŕēď.",
"kubernetesMainContainer": "Ḿàĩń ćōńţàĩńēŕ",
"kubernetesContainerCompactValue": "Ćōńţàĩńēŕ: {{value}}",
"kubernetesMainContainerHelp": "Ćōńţàĩńēŕ ţĥàţ ŕēćēĩvēś ţĥē Ķàńďēv ƀōōţśţŕàƥ àńď ŕũńś àĝēńţćţĺ.",
"kubernetesMainContainerRequired": "Ḿàĩń ćōńţàĩńēŕ ńàḿē ĩś ŕēqũĩŕēď.",
"kubernetesMainContainerInvalid": "Ḿàĩń ćōńţàĩńēŕ ḿũśţ ƀē à vàĺĩď ĎŃŚ ĺàƀēĺ.",
@@ -258,9 +260,12 @@
"kubernetesNamespaceHelp": "Ēvēŕŷ Ƥōď àńď ḿàńàĝēď ćĺàĩḿ ƒōŕ ţĥĩś ēxēćũţōŕ śţàŷś ĩń ţĥĩś ńàḿēśƥàćē.",
"kubernetesNamespaceRequired": "À ńàḿēśƥàćē ĩś ŕēqũĩŕēď.",
"kubernetesNoActiveSessions": "Ńō àćţĩvē Ķũƀēŕńēţēś śēśśĩōńś.",
"kubernetesMainContainerRequests": "Ḿàĩń-ćōńţàĩńēŕ ŕēqũēśţś",
"kubernetesOpenTask": "Ōƥēń ţàśķ {{task}}, śēśśĩōń {{session}}",
"kubernetesPlatform": "Ƥĺàţƒōŕḿ",
"kubernetesPlatformHelp": "Àďďś ţĥē ḿàţćĥĩńĝ Ķũƀēŕńēţēś àŕćĥĩţēćţũŕē śēĺēćţōŕ ţō ēvēŕŷ Ƥōď.",
"kubernetesPod": "Ƥōď",
"kubernetesPodCompactValue": "Ƥōď: {{value}}",
"kubernetesPodTemplate": "Ŕàŵ ƤōďŢēḿƥĺàţē ŶÀḾĹ",
"kubernetesPodTemplateHelp": "Ēxàćţĺŷ ōńē v1 ƤōďŢēḿƥĺàţē ďōćũḿēńţ, ũƥ ţō 256 ĶĩƁ. Ũńķńōŵń ōŕ ćōńƒĺĩćţĩńĝ ƒĩēĺďś àŕē ŕēĵēćţēď. Ţĥē śţàŕţēŕ ũśēś {{image}}, ŵĥĩćĥ ĩńćĺũďēś ƀàśēĺĩńē ţōōĺś àńď àĝēńţ ďēƥēńďēńćĩēś. Ƥĩń à ŕēĺēàśē ţàĝ ōŕ ďĩĝēśţ ƒōŕ ƥŕōďũćţĩōń.",
"kubernetesPodTemplateRequired": "ƤōďŢēḿƥĺàţē ŶÀḾĹ ĩś ŕēqũĩŕēď.",
@@ -273,7 +278,30 @@
"kubernetesRequestTimeoutHelp": "Ţĩḿēōũţ ƒōŕ Ķũƀēŕńēţēś ÀƤĨ ŕēqũēśţś, ƒŕōḿ 1 ţō 300 śēćōńďś.",
"kubernetesRestarts_one": "Ŕēśţàŕţ: {{count}}",
"kubernetesRestarts_other": "Ŕēśţàŕţś: {{count}}",
"kubernetesRequestCpu": "{{value}} ĆƤŨ",
"kubernetesRequestMemory": "{{value}} ḿēḿōŕŷ",
"kubernetesRequests": "Ŕēqũēśţś",
"kubernetesRequestsUnspecified": "Ḿàĩń-ćōńţàĩńēŕ ŕēqũēśţś: Ũńśƥēćĩƒĩēď",
"kubernetesRequestsUnspecifiedValue": "Ũńśƥēćĩƒĩēď",
"kubernetesRetentionActive": "Àćţĩvē",
"kubernetesRetentionMissing": "Ḿĩśśĩńĝ",
"kubernetesRetentionRetained": "Ŕēţàĩńēď",
"kubernetesRetentionStateValue": "Ŕēśōũŕćē ŕēţēńţĩōń: {{value}}",
"kubernetesRetentionTerminal": "Ţēŕḿĩńàĺ",
"kubernetesRetentionTerminating": "Ţēŕḿĩńàţĩńĝ",
"kubernetesRetentionUnknown": "Ũńķńōŵń",
"kubernetesSessionsFailed": "Ƒàĩĺēď ţō ĺōàď Ķũƀēŕńēţēś śēśśĩōńś.",
"kubernetesSessionCancelled": "Ćàńćēĺĺēď",
"kubernetesSessionCompleted": "Ćōḿƥĺēţēď",
"kubernetesSessionCreated": "Ćŕēàţēď",
"kubernetesSessionFailed": "Ƒàĩĺēď",
"kubernetesSessionIdle": "Ĩďĺē",
"kubernetesHideSessionDetails": "Ĥĩďē śēśśĩōń ďēţàĩĺś",
"kubernetesSessionRunning": "Ŕũńńĩńĝ",
"kubernetesShowSessionDetails": "Śĥōŵ śēśśĩōń ďēţàĩĺś",
"kubernetesSessionStarting": "Śţàŕţĩńĝ",
"kubernetesSessionStateValue": "Śēśśĩōń śţàţē: {{value}}",
"kubernetesSessionWaitingForInput": "Ŵàĩţĩńĝ ƒōŕ ĩńƥũţ",
"kubernetesStatusFailed": "Ƒàĩĺēď",
"kubernetesStatusPending": "Ƥēńďĩńĝ",
"kubernetesStatusRunning": "Ŕũńńĩńĝ",
@@ -281,6 +309,8 @@
"kubernetesStatusTerminated": "Ţēŕḿĩńàţēď",
"kubernetesStatusUnknown": "Ũńķńōŵń",
"kubernetesStatusWaiting": "Ŵàĩţĩńĝ",
"kubernetesPodStateValue": "Ƥōď śţàţē: {{value}}",
"kubernetesContainerStateValue": "Ḿàĩń-ćōńţàĩńēŕ śţàţē: {{value}}",
"kubernetesStepConfiguration": "Ćōńƒĩĝũŕàţĩōń",
"kubernetesStepDiscovery": "ÀƤĨ ďĩśćōvēŕŷ",
"kubernetesStepExistingClaim": "Ēxĩśţĩńĝ ćĺàĩḿ",
+30
View File
@@ -193,6 +193,7 @@
"kubernetesAccessModesHelp": "Selecione os modos de acesso pedidos para cada claim gerida.",
"kubernetesActiveSessions": "Sessões ativas",
"kubernetesActiveSessionsDescription": "Estado sanitizado do Pod e do espaço de trabalho para as sessões que usam este executor. Atualiza a cada 90 segundos.",
"kubernetesActiveSessionsGuidance": "Parar mantém os recursos do Kubernetes. Arquivar ou eliminar pode remover um espaço de trabalho gerido. Os claims existentes não são eliminados pelo Kandev. Os pedidos mostram CPU e memória configurados para o contentor principal, não o uso real.",
"kubernetesAdminSaveOnly": "Apenas um administrador pode guardar definições do Kubernetes.",
"kubernetesAdminTestOnly": "Apenas um administrador pode executar este teste do Kubernetes.",
"kubernetesAuthInCluster": "Conta de serviço no cluster",
@@ -235,6 +236,7 @@
"kubernetesKubeconfigPathHelp": "Caminho absoluto no anfitrião do backend do Kandev. O conteúdo do kubeconfig nunca é guardado aqui.",
"kubernetesKubeconfigPathRequired": "É necessário um caminho absoluto para o kubeconfig.",
"kubernetesMainContainer": "Contentor principal",
"kubernetesContainerCompactValue": "Contentor: {{value}}",
"kubernetesMainContainerHelp": "Contentor que recebe o arranque do Kandev e executa o agentctl.",
"kubernetesMainContainerRequired": "O nome do contentor principal é obrigatório.",
"kubernetesMainContainerInvalid": "O contentor principal tem de ser uma etiqueta DNS válida.",
@@ -242,9 +244,12 @@
"kubernetesNamespaceHelp": "Todos os Pods e claims geridas deste executor permanecem neste namespace.",
"kubernetesNamespaceRequired": "É necessário um namespace.",
"kubernetesNoActiveSessions": "Não há sessões Kubernetes ativas.",
"kubernetesMainContainerRequests": "Pedidos do contentor principal",
"kubernetesOpenTask": "Abrir tarefa {{task}}, sessão {{session}}",
"kubernetesPlatform": "Plataforma",
"kubernetesPlatformHelp": "Adiciona a cada Pod o seletor de arquitetura Kubernetes correspondente.",
"kubernetesPod": "Pod",
"kubernetesPodCompactValue": "Pod: {{value}}",
"kubernetesPodTemplate": "YAML PodTemplate bruto",
"kubernetesPodTemplateHelp": "Exatamente um documento PodTemplate v1, até 256 KiB. Os campos desconhecidos ou em conflito são rejeitados. O modelo inicial usa {{image}}, que inclui as ferramentas base e as dependências dos agentes. Para produção, fixe uma etiqueta de versão ou um digest.",
"kubernetesPodTemplateRequired": "O YAML do PodTemplate é obrigatório.",
@@ -257,7 +262,30 @@
"kubernetesRequestTimeoutHelp": "Tempo limite dos pedidos à API do Kubernetes, entre 1 e 300 segundos.",
"kubernetesRestarts_one": "Reinício: {{count}}",
"kubernetesRestarts_other": "Reinícios: {{count}}",
"kubernetesRequestCpu": "{{value}} de CPU",
"kubernetesRequestMemory": "{{value}} memória",
"kubernetesRequests": "Pedidos",
"kubernetesRequestsUnspecified": "Pedidos do contentor principal: Não especificado",
"kubernetesRequestsUnspecifiedValue": "Não especificado",
"kubernetesRetentionActive": "Ativo",
"kubernetesRetentionMissing": "Em falta",
"kubernetesRetentionRetained": "Retido",
"kubernetesRetentionStateValue": "Retenção de recursos: {{value}}",
"kubernetesRetentionTerminal": "Estado final",
"kubernetesRetentionTerminating": "A terminar",
"kubernetesRetentionUnknown": "Desconhecido",
"kubernetesSessionsFailed": "Não foi possível carregar as sessões Kubernetes.",
"kubernetesSessionCancelled": "Cancelada",
"kubernetesSessionCompleted": "Concluída",
"kubernetesSessionCreated": "Criada",
"kubernetesSessionFailed": "Falhou",
"kubernetesSessionIdle": "Inativa",
"kubernetesHideSessionDetails": "Ocultar detalhes da sessão",
"kubernetesSessionRunning": "Em execução",
"kubernetesShowSessionDetails": "Mostrar detalhes da sessão",
"kubernetesSessionStarting": "A iniciar",
"kubernetesSessionStateValue": "Estado da sessão: {{value}}",
"kubernetesSessionWaitingForInput": "A aguardar entrada",
"kubernetesStatusFailed": "Falhou",
"kubernetesStatusPending": "Pendente",
"kubernetesStatusRunning": "Em execução",
@@ -265,6 +293,8 @@
"kubernetesStatusTerminated": "Terminada",
"kubernetesStatusUnknown": "Desconhecido",
"kubernetesStatusWaiting": "Em espera",
"kubernetesPodStateValue": "Estado do Pod: {{value}}",
"kubernetesContainerStateValue": "Estado do contentor principal: {{value}}",
"kubernetesStepConfiguration": "Configuração",
"kubernetesStepDiscovery": "Descoberta da API",
"kubernetesStepExistingClaim": "Claim existente",
+30
View File
@@ -193,6 +193,7 @@
"kubernetesAccessModesHelp": "选择每个托管存储声明请求的访问模式。",
"kubernetesActiveSessions": "活动会话",
"kubernetesActiveSessionsDescription": "使用此执行器的会话所对应的 Pod 和工作区安全状态。每 90 秒刷新一次。",
"kubernetesActiveSessionsGuidance": "停止会保留 Kubernetes 资源。归档或删除可能会移除受 Kandev 管理的工作区。Kandev 不会删除现有的声明。请求值显示主容器配置的 CPU 和内存,不是实际使用量。",
"kubernetesAdminSaveOnly": "只有管理员可以保存 Kubernetes 设置。",
"kubernetesAdminTestOnly": "只有管理员可以运行此 Kubernetes 测试。",
"kubernetesAuthInCluster": "集群内服务账号",
@@ -235,6 +236,7 @@
"kubernetesKubeconfigPathHelp": "Kandev 后端主机上的绝对路径。此处绝不存储 kubeconfig 内容。",
"kubernetesKubeconfigPathRequired": "必须填写 kubeconfig 的绝对路径。",
"kubernetesMainContainer": "主容器",
"kubernetesContainerCompactValue": "容器:{{value}}",
"kubernetesMainContainerHelp": "接收 Kandev 启动配置并运行 agentctl 的容器。",
"kubernetesMainContainerRequired": "必须填写主容器名称。",
"kubernetesMainContainerInvalid": "主容器名称必须是有效的 DNS 标签。",
@@ -242,9 +244,12 @@
"kubernetesNamespaceHelp": "此执行器的所有 Pod 和托管存储声明都保留在此命名空间中。",
"kubernetesNamespaceRequired": "必须填写命名空间。",
"kubernetesNoActiveSessions": "没有活动的 Kubernetes 会话。",
"kubernetesMainContainerRequests": "主容器资源请求",
"kubernetesOpenTask": "打开任务 {{task}},会话 {{session}}",
"kubernetesPlatform": "平台",
"kubernetesPlatformHelp": "为每个 Pod 添加匹配的 Kubernetes 架构选择器。",
"kubernetesPod": "Pod",
"kubernetesPodCompactValue": "Pod:{{value}}",
"kubernetesPodTemplate": "原始 PodTemplate YAML",
"kubernetesPodTemplateHelp": "只允许一个 v1 PodTemplate 文档,最大 256 KiB。未知或冲突字段会被拒绝。初始模板使用 {{image}},其中包含基础工具和智能体依赖项。生产环境请固定版本标签或摘要。",
"kubernetesPodTemplateRequired": "必须填写 PodTemplate YAML。",
@@ -257,7 +262,30 @@
"kubernetesRequestTimeoutHelp": "Kubernetes API 请求的超时时间,范围为 1 到 300 秒。",
"kubernetesRestarts_one": "重启次数:{{count}}",
"kubernetesRestarts_other": "重启次数:{{count}}",
"kubernetesRequestCpu": "{{value}} 个 CPU",
"kubernetesRequestMemory": "{{value}} 内存",
"kubernetesRequests": "资源请求",
"kubernetesRequestsUnspecified": "主容器资源请求:未指定",
"kubernetesRequestsUnspecifiedValue": "未指定",
"kubernetesRetentionActive": "活跃",
"kubernetesRetentionMissing": "缺失",
"kubernetesRetentionRetained": "保留中",
"kubernetesRetentionStateValue": "资源保留状态:{{value}}",
"kubernetesRetentionTerminal": "已结束",
"kubernetesRetentionTerminating": "终止中",
"kubernetesRetentionUnknown": "未知",
"kubernetesSessionsFailed": "无法加载 Kubernetes 会话。",
"kubernetesSessionCancelled": "已取消",
"kubernetesSessionCompleted": "已完成",
"kubernetesSessionCreated": "已创建",
"kubernetesSessionFailed": "失败",
"kubernetesSessionIdle": "空闲",
"kubernetesHideSessionDetails": "隐藏会话详细信息",
"kubernetesSessionRunning": "运行中",
"kubernetesShowSessionDetails": "显示会话详细信息",
"kubernetesSessionStarting": "启动中",
"kubernetesSessionStateValue": "会话状态:{{value}}",
"kubernetesSessionWaitingForInput": "等待输入",
"kubernetesStatusFailed": "失败",
"kubernetesStatusPending": "等待调度",
"kubernetesStatusRunning": "运行中",
@@ -265,6 +293,8 @@
"kubernetesStatusTerminated": "已终止",
"kubernetesStatusUnknown": "未知",
"kubernetesStatusWaiting": "等待中",
"kubernetesPodStateValue": "Pod 状态:{{value}}",
"kubernetesContainerStateValue": "主容器状态:{{value}}",
"kubernetesStepConfiguration": "配置",
"kubernetesStepDiscovery": "API 发现",
"kubernetesStepExistingClaim": "现有存储声明",
+30
View File
@@ -193,6 +193,7 @@
"kubernetesAccessModesHelp": "選擇每個託管存儲聲明請求的存取模式。",
"kubernetesActiveSessions": "活動工作階段",
"kubernetesActiveSessionsDescription": "使用此執行器的工作階段所對應的 Pod 和工作區安全狀態。每 90 秒刷新一次。",
"kubernetesActiveSessionsGuidance": "停止會保留 Kubernetes 資源。歸檔或刪除可能會移除受 Kandev 管理的工作區。Kandev 不會刪除現有的聲明。請求值顯示主容器設定的 CPU 和記憶體,不是實際使用量。",
"kubernetesAdminSaveOnly": "只有管理員可以儲存 Kubernetes 設定。",
"kubernetesAdminTestOnly": "只有管理員可以執行此 Kubernetes 測試。",
"kubernetesAuthInCluster": "集羣內服務帳戶",
@@ -235,6 +236,7 @@
"kubernetesKubeconfigPathHelp": "Kandev 後端主機上的絕對路徑。此處絕不存儲 kubeconfig 內容。",
"kubernetesKubeconfigPathRequired": "必須填寫 kubeconfig 的絕對路徑。",
"kubernetesMainContainer": "主容器",
"kubernetesContainerCompactValue": "容器:{{value}}",
"kubernetesMainContainerHelp": "接收 Kandev 啓動設定並執行 agentctl 的容器。",
"kubernetesMainContainerRequired": "必須填寫主容器名稱。",
"kubernetesMainContainerInvalid": "主容器名稱必須是有效的 DNS 標籤。",
@@ -242,9 +244,12 @@
"kubernetesNamespaceHelp": "此執行器的所有 Pod 和託管存儲聲明都保留在此命名空間中。",
"kubernetesNamespaceRequired": "必須填寫命名空間。",
"kubernetesNoActiveSessions": "沒有活動的 Kubernetes 工作階段。",
"kubernetesMainContainerRequests": "主容器資源請求",
"kubernetesOpenTask": "開啟任務 {{task}},工作階段 {{session}}",
"kubernetesPlatform": "平台",
"kubernetesPlatformHelp": "為每個 Pod 新增匹配的 Kubernetes 架構選擇器。",
"kubernetesPod": "Pod",
"kubernetesPodCompactValue": "Pod:{{value}}",
"kubernetesPodTemplate": "原始 PodTemplate YAML",
"kubernetesPodTemplateHelp": "只允許一個 v1 PodTemplate 文檔,最大 256 KiB。未知或衝突欄位會被拒絕。初始模板使用 {{image}},其中包含基礎工具和代理程式依賴項。生產環境請固定版本標籤或摘要。",
"kubernetesPodTemplateRequired": "必須填寫 PodTemplate YAML。",
@@ -257,7 +262,30 @@
"kubernetesRequestTimeoutHelp": "Kubernetes API 請求的超時時間,範圍為 1 到 300 秒。",
"kubernetesRestarts_one": "重啓次數:{{count}}",
"kubernetesRestarts_other": "重啓次數:{{count}}",
"kubernetesRequestCpu": "{{value}} 個 CPU",
"kubernetesRequestMemory": "{{value}} 記憶體",
"kubernetesRequests": "資源請求",
"kubernetesRequestsUnspecified": "主容器資源請求:未指定",
"kubernetesRequestsUnspecifiedValue": "未指定",
"kubernetesRetentionActive": "活躍",
"kubernetesRetentionMissing": "缺失",
"kubernetesRetentionRetained": "保留中",
"kubernetesRetentionStateValue": "資源保留狀態:{{value}}",
"kubernetesRetentionTerminal": "已結束",
"kubernetesRetentionTerminating": "終止中",
"kubernetesRetentionUnknown": "未知",
"kubernetesSessionsFailed": "無法載入 Kubernetes 工作階段。",
"kubernetesSessionCancelled": "已取消",
"kubernetesSessionCompleted": "已完成",
"kubernetesSessionCreated": "已建立",
"kubernetesSessionFailed": "失敗",
"kubernetesSessionIdle": "空閒",
"kubernetesHideSessionDetails": "隱藏工作階段詳細資訊",
"kubernetesSessionRunning": "執行中",
"kubernetesShowSessionDetails": "顯示工作階段詳細資訊",
"kubernetesSessionStarting": "啓動中",
"kubernetesSessionStateValue": "工作階段狀態:{{value}}",
"kubernetesSessionWaitingForInput": "等待輸入",
"kubernetesStatusFailed": "失敗",
"kubernetesStatusPending": "等待調度",
"kubernetesStatusRunning": "執行中",
@@ -265,6 +293,8 @@
"kubernetesStatusTerminated": "已終止",
"kubernetesStatusUnknown": "未知",
"kubernetesStatusWaiting": "等待中",
"kubernetesPodStateValue": "Pod 狀態:{{value}}",
"kubernetesContainerStateValue": "主容器狀態:{{value}}",
"kubernetesStepConfiguration": "設定",
"kubernetesStepDiscovery": "API 發現",
"kubernetesStepExistingClaim": "現有存儲聲明",
+30
View File
@@ -193,6 +193,7 @@
"kubernetesAccessModesHelp": "選擇每個託管儲存宣告請求的存取模式。",
"kubernetesActiveSessions": "活動工作階段",
"kubernetesActiveSessionsDescription": "使用此執行器的工作階段所對應的 Pod 和工作區安全狀態。每 90 秒重新整理一次。",
"kubernetesActiveSessionsGuidance": "停止會保留 Kubernetes 資源。歸檔或刪除可能會移除受 Kandev 管理的工作區。Kandev 不會刪除現有的宣告。請求值顯示主容器設定的 CPU 和記憶體,不是實際使用量。",
"kubernetesAdminSaveOnly": "只有管理員可以儲存 Kubernetes 設定。",
"kubernetesAdminTestOnly": "只有管理員可以執行此 Kubernetes 測試。",
"kubernetesAuthInCluster": "叢集內服務帳戶",
@@ -235,6 +236,7 @@
"kubernetesKubeconfigPathHelp": "Kandev 後端主機上的絕對路徑。此處絕不儲存 kubeconfig 內容。",
"kubernetesKubeconfigPathRequired": "必須填寫 kubeconfig 的絕對路徑。",
"kubernetesMainContainer": "主容器",
"kubernetesContainerCompactValue": "容器:{{value}}",
"kubernetesMainContainerHelp": "接收 Kandev 啟動設定並執行 agentctl 的容器。",
"kubernetesMainContainerRequired": "必須填寫主容器名稱。",
"kubernetesMainContainerInvalid": "主容器名稱必須是有效的 DNS 標籤。",
@@ -242,9 +244,12 @@
"kubernetesNamespaceHelp": "此執行器的所有 Pod 和託管儲存宣告都保留在此名稱空間中。",
"kubernetesNamespaceRequired": "必須填寫名稱空間。",
"kubernetesNoActiveSessions": "沒有活動的 Kubernetes 工作階段。",
"kubernetesMainContainerRequests": "主容器資源請求",
"kubernetesOpenTask": "開啟任務 {{task}},工作階段 {{session}}",
"kubernetesPlatform": "平臺",
"kubernetesPlatformHelp": "為每個 Pod 新增匹配的 Kubernetes 架構選擇器。",
"kubernetesPod": "Pod",
"kubernetesPodCompactValue": "Pod:{{value}}",
"kubernetesPodTemplate": "原始 PodTemplate YAML",
"kubernetesPodTemplateHelp": "只允許一個 v1 PodTemplate 檔案,最大 256 KiB。未知或衝突欄位會被拒絕。初始模板使用 {{image}},其中包含基礎工具和代理程式依賴項。生產環境請固定版本標籤或摘要。",
"kubernetesPodTemplateRequired": "必須填寫 PodTemplate YAML。",
@@ -257,7 +262,30 @@
"kubernetesRequestTimeoutHelp": "Kubernetes API 請求的超時時間,範圍為 1 到 300 秒。",
"kubernetesRestarts_one": "重啟次數:{{count}}",
"kubernetesRestarts_other": "重啟次數:{{count}}",
"kubernetesRequestCpu": "{{value}} 個 CPU",
"kubernetesRequestMemory": "{{value}} 記憶體",
"kubernetesRequests": "資源請求",
"kubernetesRequestsUnspecified": "主容器資源請求:未指定",
"kubernetesRequestsUnspecifiedValue": "未指定",
"kubernetesRetentionActive": "活躍",
"kubernetesRetentionMissing": "缺失",
"kubernetesRetentionRetained": "保留中",
"kubernetesRetentionStateValue": "資源保留狀態:{{value}}",
"kubernetesRetentionTerminal": "已結束",
"kubernetesRetentionTerminating": "終止中",
"kubernetesRetentionUnknown": "未知",
"kubernetesSessionsFailed": "無法載入 Kubernetes 工作階段。",
"kubernetesSessionCancelled": "已取消",
"kubernetesSessionCompleted": "已完成",
"kubernetesSessionCreated": "已建立",
"kubernetesSessionFailed": "失敗",
"kubernetesSessionIdle": "空閒",
"kubernetesHideSessionDetails": "隱藏工作階段詳細資訊",
"kubernetesSessionRunning": "執行中",
"kubernetesShowSessionDetails": "顯示工作階段詳細資訊",
"kubernetesSessionStarting": "啟動中",
"kubernetesSessionStateValue": "工作階段狀態:{{value}}",
"kubernetesSessionWaitingForInput": "等待輸入",
"kubernetesStatusFailed": "失敗",
"kubernetesStatusPending": "等待排程",
"kubernetesStatusRunning": "執行中",
@@ -265,6 +293,8 @@
"kubernetesStatusTerminated": "已終止",
"kubernetesStatusUnknown": "未知",
"kubernetesStatusWaiting": "等待中",
"kubernetesPodStateValue": "Pod 狀態:{{value}}",
"kubernetesContainerStateValue": "主容器狀態:{{value}}",
"kubernetesStepConfiguration": "設定",
"kubernetesStepDiscovery": "API 發現",
"kubernetesStepExistingClaim": "現有儲存宣告",
@@ -0,0 +1,94 @@
---
created: 2026-09-09
status: implemented
requirements:
- REQ-EXECUTORS-K8S-RETAINED-001
- REQ-EXECUTORS-K8S-RETAINED-002
system_design:
- ../../specs/executors/system-design/kubernetes-retained-compute.md
legacy_specs:
- ../../specs/kubernetes-executor/spec.md
---
# Implementation Plan: Kubernetes Retained Compute Visibility
## Overview
Show recorded session state alongside verified Pod state and main-container requests.
First extend the shared read-only backend projection. Then expose it in the
existing desktop table and mobile cards, with task navigation and retention guidance.
Deliver this after [worker presets](../kubernetes-worker-presets/plan.md) and
[startup timing](../kubernetes-startup-timing/plan.md) by preference.
There is no code dependency on either package. Shared documentation and Kind tests
make sequential integration preferable.
## Scope
### In scope
- Additive session, retention, and main-container request fields on the existing status response.
- Active sessions desktop/mobile presentation, task navigation, and localized guidance.
- Unit, authorization, responsive E2E, and real Stop/Resume evidence.
### Out of scope
- Auto-cleanup, new destructive controls, metrics-server access, aggregate cost, or changed resource ownership.
- New inventory tables, background pollers, or changes to the admin-only session-impact count.
## Technical approach
Extend `SessionRow` and `sessionRow` in `internal/kubernetes/sessions.go`.
Use its existing task-session read and identity-verified Pod GET.
Derive the design's finite retention state in a local helper and emit only
allowlisted main-container CPU/memory quantities.
Extend `lib/types/http-kubernetes.ts` with optional compatibility fields.
The existing `useKubernetesSessions` hook supplies both viewports.
Extend `KubernetesSessionsCard` through small helpers for labels, requests, and navigation.
Use backend classifications rather than another frontend state machine.
## Tests
| Acceptance | Evidence |
| --- | --- |
| RETAINED-001.1 through 001.3 | `TestKubernetesRetentionProjection` with every canonical session state and Pod outcome |
| RETAINED-001.4 | `TestKubernetesMainContainerRequests` with missing/zero quantities and admitted values |
| RETAINED-002.5 | Existing HTTP/WS authorization tests plus exact GET action counts |
| RETAINED-001.5 | Hook/component refresh, error, and stale-response cases |
| RETAINED-002.1 through 002.4 | Desktop/mobile E2E, translated copy, and semantic task navigation |
IDs use the `AC-EXECUTORS-K8S-` prefix. New tests precede production changes.
## E2E tests
- `tests/settings/kubernetes-executor.spec.ts`, `chromium`: recorded session and
Pod state, request values, refresh/error transitions, safe guidance, and task navigation.
- `tests/settings/mobile-kubernetes-executor.spec.ts`, `mobile-chrome`: the same
outcome through tappable phone cards, long identifiers, and viewport containment.
- `tests/kubernetes/kubernetes-executor.spec.ts`, `containers`: actual managed-PVC
Stop/Resume updates the profile status while Pod/PVC UIDs remain unchanged.
The mobile exemplar is the current `MobileSessionList`, with direct task navigation
matching `kanban-with-preview.tsx`. No new overlay or vertical scroll owner is introduced.
## Work orders
- [x] [Task 01: Project retained Kubernetes resources](task-01-retention-projection.md)
- [x] [Task 02: Show retained resource status](task-02-retention-status.md)
## Verification results
Implemented. Backend projection, translated desktop/mobile presentation, and
real managed-PVC Stop/Resume/terminal cleanup evidence passed with exact task
and resource identities preserved.
The final presentation uses two-line desktop rows with one optional diagnostic
row. Phone cards use the same compact status hierarchy and remain task links.
## Risks
- A Pending Pod is retained but not proof of allocated compute or charges.
- Session state and Pod state are separate observations, so refresh must tolerate transitions.
- Sidecars and Pod-level resources are excluded. The UI must state that scope.
- Archive/Delete guidance describes possible workspace loss. It must not look like a harmless compute-release action.
@@ -0,0 +1,107 @@
---
id: "01-retention-projection"
title: "Project retained Kubernetes resources"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-EXECUTORS-K8S-RETAINED-001
- REQ-EXECUTORS-K8S-RETAINED-002
acceptance_criteria:
- AC-EXECUTORS-K8S-RETAINED-001.1
- AC-EXECUTORS-K8S-RETAINED-001.2
- AC-EXECUTORS-K8S-RETAINED-001.3
- AC-EXECUTORS-K8S-RETAINED-001.4
- AC-EXECUTORS-K8S-RETAINED-002.5
system_design:
- ../../specs/executors/system-design/kubernetes-retained-compute.md
---
# Task 01: Project Retained Kubernetes Resources
## Summary
Extend the existing sanitized session response with recorded session state,
verified retention classification, and main-container requests.
Use only the database and Pod observations the endpoint already performs.
## In scope
- Add `session_state`, `retention_state`, and optional `main_container_requests` to `SessionRow`.
- Implement the design's precedence table at the existing authorization/identity boundary.
- Keep current HTTP and WS response fields compatible and add contract tests.
- Cover ordinary stopped inventory without filtering it through active-agent status.
## Out of scope
- UI, persistence changes, new cluster permissions, cleanup changes, or session-impact semantics.
- Pod-wide totals, metrics-server calls, or frontend classification logic.
## Acceptance
- Table tests cover every canonical session state, Pod phase, deletion timestamp, and failed observation.
- CPU/memory values come only from the verified main container, with explicit zero preserved and missing values omitted.
- Existing authorization and exact-inventory filters apply to every new field without extra cluster calls.
## Verification
From `apps/backend`:
```bash
rtk go test ./internal/kubernetes -run 'TestKubernetes(RetentionProjection|MainContainerRequests)' -count=1
rtk go test ./internal/kubernetes -count=1
```
From the repository root:
```bash
rtk git diff --check
```
The named projection tests are new outputs. TDD tests must inspect both HTTP
and WS serialization, access rejection, identity mismatch, and fake-client actions.
They must not mirror only a helper's expected mapping without exercising `sessionRow`.
## Files likely touched
- `apps/backend/internal/kubernetes/sessions.go`
- `apps/backend/internal/kubernetes/sessions_test.go`
- `apps/backend/internal/kubernetes/sessions_retention.go` (new if needed for file limits)
- `apps/backend/internal/kubernetes/sessions_retention_test.go` (new)
## Dependencies
None. Existing Kubernetes fake clients support these tests without a cluster.
## Risks
- `WAITING_FOR_INPUT` does not mean stopped. A retained Pod does not prove the absence of background work.
- Missing or foreign Pods cannot supply trustworthy resource values.
- Session-state additions must map to unknown until the classification contract explicitly includes them.
## Parallelism
`sequential`
## Inputs
- [Retained-compute requirements](../../specs/executors/requirements/kubernetes-retained-compute.md)
- [Retained-compute design](../../specs/executors/system-design/kubernetes-retained-compute.md)
- `apps/backend/internal/kubernetes/sessions_namespace_test.go`
- `apps/backend/internal/task/models/models.go` for canonical session states
- `docs/decisions/2026-08-24-kubernetes-executor-resource-ownership.md`
## Results
Passed:
- `rtk go test ./internal/kubernetes -run 'TestKubernetes(RetentionProjection|MainContainerRequests)' -count=1`
(6 tests)
- `rtk go test ./internal/kubernetes -count=1`
(131 tests)
- `rtk git diff --check`
The projection table explicitly covers Created, Starting, Running,
WaitingForInput, Idle, Completed, Failed, and Cancelled session states,
including ordinary active Pods and retained sessions.
@@ -0,0 +1,153 @@
---
id: "02-retention-status"
title: "Show retained resource status"
status: done
wave: 2
depends_on:
- "01-retention-projection"
plan: "plan.md"
requirements:
- REQ-EXECUTORS-K8S-RETAINED-001
- REQ-EXECUTORS-K8S-RETAINED-002
acceptance_criteria:
- AC-EXECUTORS-K8S-RETAINED-001.1
- AC-EXECUTORS-K8S-RETAINED-001.2
- AC-EXECUTORS-K8S-RETAINED-001.3
- AC-EXECUTORS-K8S-RETAINED-001.4
- AC-EXECUTORS-K8S-RETAINED-001.5
- AC-EXECUTORS-K8S-RETAINED-002.1
- AC-EXECUTORS-K8S-RETAINED-002.2
- AC-EXECUTORS-K8S-RETAINED-002.3
- AC-EXECUTORS-K8S-RETAINED-002.4
- AC-EXECUTORS-K8S-RETAINED-002.5
system_design:
- ../../specs/executors/system-design/kubernetes-retained-compute.md
---
# Task 02: Show Retained Resource Status
## Summary
Expose the new projection in the current Active sessions table and phone cards.
Provide direct task navigation and visible guidance about resource retention.
## In scope
- Add optional frontend response fields and translated label/request helpers.
- Extend the existing component with separate session and Pod facts, main-container requests, and task navigation.
- Preserve existing polling, scope guards, and error behavior.
- Add component tests and extend desktop/mobile E2E for rendering, refresh, navigation, and geometry.
- Extend the real Kind managed-PVC scenario with profile-status assertions before Stop, after Stop, and after Resume.
- Update the reference/explanation sections in `docs/public/k8s.md` and relevant executor guidance.
## Out of scope
- New destructive buttons, global totals, saved filters, task-status schemas, or background polling.
- Changes to existing task route behavior or archive/delete authorization.
## Acceptance
- Desktop and phone show the same backend facts and navigate to the intended task without a mutation from the status controls.
- Refresh and real Stop/Resume evidence prove truthful retained classification, while unknown/error fields remain explicit.
- Translated guidance, touch targets, keyboard links, and long content pass focused rendered tests without document overflow.
## Verification
From `apps`, once for a fresh worktree:
```bash
rtk pnpm install --frozen-lockfile
```
From `apps/web`:
```bash
rtk pnpm exec vitest run components/settings/kubernetes-sessions-card.test.tsx lib/api/domains/kubernetes-api.test.ts hooks/domains/settings/use-kubernetes-settings.test.tsx
rtk pnpm run typecheck
rtk pnpm run i18n:check
rtk pnpm run i18n:ratchet
rtk pnpm e2e:run --project chromium tests/settings/kubernetes-executor.spec.ts
rtk pnpm e2e:run --project mobile-chrome tests/settings/mobile-kubernetes-executor.spec.ts
rtk env KANDEV_E2E_CONTAINERS=1 pnpm e2e:run --project containers tests/kubernetes/kubernetes-executor.spec.ts -- --grep 'preserves a managed PVC'
```
From the repository root:
```bash
rtk node --test scripts/validate-public-docs.test.mjs
rtk node scripts/validate-public-docs.mjs
rtk git diff --check
```
Use TDD for new behavior. Run E2E commands sequentially with current builds.
The mobile scenario uses `.tap()`, asserts the card's task destination, and verifies
44px targets plus no document overflow. Capture one phone and desktop screenshot.
Generate Traditional Chinese values with `pnpm run i18n:zh-hant` during implementation.
## Files likely touched
- `apps/web/lib/types/http-kubernetes.ts`
- `apps/web/lib/api/domains/kubernetes-api.test.ts`
- `apps/web/components/settings/kubernetes-sessions-card.tsx`
- `apps/web/components/settings/kubernetes-sessions-card.test.tsx` (new)
- `apps/web/hooks/domains/settings/use-kubernetes-settings.test.tsx`
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/executors.json`
- `apps/web/e2e/tests/settings/kubernetes-executor.spec.ts`
- `apps/web/e2e/tests/settings/mobile-kubernetes-executor.spec.ts`
- `apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts`
- `docs/public/k8s.md`, `docs/public/executors.md`
## Dependencies
Task `01-retention-projection`. Real lifecycle evidence requires host Docker and
the current Kind fixture. UI response fixtures do not replace this evidence.
## Risks
- Old responses need unknown/unspecified fallbacks, not a duplicated frontend classifier.
- A whole-card phone link cannot contain nested interactive controls.
- Guidance must distinguish managed PVC deletion from operator-owned existing claims.
- Preserve changes from earlier packages in the shared Kind spec and public documentation.
## Parallelism
`sequential`
## Inputs
- [Retained-compute requirements](../../specs/executors/requirements/kubernetes-retained-compute.md)
- [Retained-compute design](../../specs/executors/system-design/kubernetes-retained-compute.md)
- `MobileSessionList` in `kubernetes-sessions-card.tsx`
- `apps/web/components/kanban-with-preview.tsx` for direct phone task navigation
- Existing desktop/mobile Kubernetes configuration E2E specs
- `.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md`
## Results
Passed:
- `rtk pnpm exec vitest run components/settings/kubernetes-sessions-card.test.tsx lib/api/domains/kubernetes-api.test.ts hooks/domains/settings/use-kubernetes-settings.test.tsx`
(24 tests, including distinct Pod phase and main-container state coverage on
desktop and mobile)
- `rtk pnpm run typecheck`
- `rtk pnpm run i18n:check`
- `rtk pnpm run i18n:ratchet`
- `rtk pnpm e2e:run --host --no-build --project chromium tests/settings/kubernetes-executor.spec.ts`
(4 tests passed)
- `rtk pnpm e2e:run --host --no-build --project mobile-chrome tests/settings/mobile-kubernetes-executor.spec.ts`
(3 tests passed)
- `rtk env KANDEV_E2E_CONTAINERS=1 pnpm e2e:run --host --no-build --project containers tests/kubernetes/kubernetes-executor.spec.ts -- --grep 'preserves a managed PVC'`
(1 test passed in 53 seconds; retained and active projections matched
Stop/Resume, and terminal cleanup removed the managed resources)
- `rtk node --test scripts/validate-public-docs.test.mjs`
- `rtk node scripts/validate-public-docs.mjs`
- `rtk git diff --check`
Compact-row follow-up passed:
- The focused component, API, and hook suite passed 32 tests.
- The desktop E2E suite passed 4 tests. The session row remained at most 96 px
high, and the Created cell remained inside the table.
- The phone E2E suite passed 3 tests. The status group remained at most 80 px
high with no document overflow.
- Production-build screenshots confirmed the compact desktop row and phone card.
@@ -0,0 +1,80 @@
---
created: 2026-09-09
status: implemented
requirements:
- REQ-EXECUTORS-K8S-TIMING-001
- REQ-EXECUTORS-K8S-TIMING-002
system_design:
- ../../specs/executors/system-design/kubernetes-startup-timing.md
legacy_specs:
- ../../specs/kubernetes-executor/spec.md
---
# Implementation Plan: Kubernetes Startup Timing
## Overview
Expose fresh environment stage durations through bounded structured logs.
This package shares the first delivery group with
[worker presets](../kubernetes-worker-presets/plan.md). Either can land first.
Timing data informs future optimization without changing the executor architecture.
## Scope
### In scope
- Per-attempt storage, Pod-readiness, bootstrap, connection, and total durations.
- Explicit failure/cancellation outcomes, correlation, privacy tests, and diagnostic documentation.
### Out of scope
- A metrics registry, new UI stages, historical storage, reconnect measurements, or runtime policy changes.
- First-response latency claims and per-image-pull attribution.
## Technical approach
Add a Kubernetes-local recorder near `KubernetesExecutor.createFresh`.
Use the existing Zap logger and monotonic elapsed time. Bind records to one
attempt ID and allowlisted lifecycle identity fields.
Keep all cluster calls, return errors, checkpoint ordering, and rollback ownership unchanged.
The summary records final return after rollback, not full manager/agent readiness.
## Tests
`executor_kubernetes_timing_test.go` contains these proposed tests:
- `TestKubernetesLaunchTimingSuccess`: TIMING-001.1, 001.2, 002.3.
- `TestKubernetesLaunchTimingFailures`: TIMING-001.3, 002.1.
- `TestKubernetesLaunchTimingFinalization`: TIMING-001.2, 002.1.
- `TestKubernetesLaunchTimingSecrets`: TIMING-002.2.
- `TestKubernetesLaunchTimingConcurrentAttempts`: TIMING-002.3.
- `TestKubernetesLaunchTimingReconnectExcluded`: TIMING-001.4, 002.1.
IDs use the `AC-EXECUTORS-K8S-` prefix. Test fake-client action counts, bounded
record counts, and original errors through the production launch path.
## E2E tests
Extend the existing kubeconfig launch case in
`tests/kubernetes/kubernetes-executor.spec.ts`, project `containers`.
Read its isolated backend file log with Info logging enabled.
Assert one correlated total and the four successful stages for its fresh instance.
The scenario still verifies a working task. It sets no duration threshold.
No UI change requires new mobile tests.
## Work orders
- [x] [Task 01: Record Kubernetes launch durations](task-01-launch-durations.md)
## Verification results
Implemented. Unit timing coverage and the real Kind kubeconfig launch both
produce one correlated summary and four stage records without changing launch
errors or adding a duration threshold.
## Risks
- Labeling this total as agent readiness creates misleading performance claims.
- Deferred rollback can change the final error. The summary must observe that result.
- Info-level stdout filtering differs from backend file logging. The test must read the correct log sink.
- A process crash can prevent the final record. The implementation cannot claim crash-durable timings.
@@ -0,0 +1,127 @@
---
id: "01-launch-durations"
title: "Record Kubernetes launch durations"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-EXECUTORS-K8S-TIMING-001
- REQ-EXECUTORS-K8S-TIMING-002
acceptance_criteria:
- AC-EXECUTORS-K8S-TIMING-001.1
- AC-EXECUTORS-K8S-TIMING-001.2
- AC-EXECUTORS-K8S-TIMING-001.3
- AC-EXECUTORS-K8S-TIMING-001.4
- AC-EXECUTORS-K8S-TIMING-002.1
- AC-EXECUTORS-K8S-TIMING-002.2
- AC-EXECUTORS-K8S-TIMING-002.3
system_design:
- ../../specs/executors/system-design/kubernetes-startup-timing.md
---
# Task 01: Record Kubernetes Launch Durations
## Summary
Add passive stage and total duration records around fresh Kubernetes environment creation.
Provide enough evidence to diagnose a slow or failed attempt without exposing its inputs.
## In scope
- Implement the design's local recorder, monotonic clock, correlation, fixed outcomes, and stage boundaries.
- Cover success, all stage failures, finalization, rollback, concurrent calls, and secret exclusion through TDD.
- Add one real Kind log assertion to the existing successful launch case.
- Add a diagnostic reference section to `docs/public/k8s.md` with log fields and measurement exclusions.
## Out of scope
- Generic lifecycle tracing, metrics endpoints, persistent timing rows, UI progress, or retries.
- Changes to `KubernetesPreparer` or the meaning of existing launch events.
## Acceptance
- Every returned fresh attempt emits one total and at most four stage records with truthful outcome and duration.
- Observer and action-count tests prove privacy, unchanged return errors, and no extra cluster operations.
- The real launch scenario produces correlated records while reconnect paths produce no fresh-start records.
## Verification
From `apps/backend`:
```bash
rtk go test ./internal/agent/runtime/lifecycle -run 'TestKubernetesLaunchTiming' -count=1
rtk go test ./internal/agent/runtime/lifecycle -run 'TestKubernetes' -count=1
```
From `apps`, once for a fresh worktree:
```bash
rtk pnpm install --frozen-lockfile
```
From `apps/web`:
```bash
rtk env KANDEV_LOG_LEVEL=info KANDEV_E2E_CONTAINERS=1 pnpm e2e:run --project containers tests/kubernetes/kubernetes-executor.spec.ts -- --grep 'launches through kubeconfig'
```
From the repository root:
```bash
rtk node --test scripts/validate-public-docs.test.mjs
rtk node scripts/validate-public-docs.mjs
rtk git diff --check
```
The timing tests are new outputs. Use a Zap observer and deterministic time,
not sleeps or timing thresholds. Read the isolated backend's retained file log
rather than assuming Info records appear on stdout.
## Files likely touched
- `apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go`
- `apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing.go` (new)
- `apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing_test.go` (new)
- `apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts`
- `apps/web/e2e/helpers/kubernetes.ts` only for an isolated log reader if needed
- `docs/public/k8s.md`
## Dependencies
None. The Kind verification needs host Docker and the existing pinned tools.
Coordinate sequential edits with the other Kubernetes plans because they share tests and documentation.
## Risks
- Record finalization failure separately from an entered stage failure.
- Register the summary defer before rollback so it sees the final result.
- Do not log request metadata, raw errors, tokens, nonces, or scripts.
## Parallelism
`sequential`
## Inputs
- [Timing requirements](../../specs/executors/requirements/kubernetes-startup-timing.md)
- [Timing design](../../specs/executors/system-design/kubernetes-startup-timing.md)
- `executor_kubernetes_fakes_test.go`, `executor_kubernetes_checkpoint_test.go`, and `executor_kubernetes_restart_test.go` in the lifecycle package
- `manager_stream_disconnect_log_test.go` for the existing Zap observer pattern
## Results
Passed:
- `rtk go test ./internal/agent/runtime/lifecycle -run 'TestKubernetesLaunchTiming' -count=1`
(8 tests)
- `rtk go test ./internal/agent/runtime/lifecycle -run 'TestKubernetes' -count=1`
(115 tests)
- `rtk env KANDEV_LOG_LEVEL=info KANDEV_E2E_CONTAINERS=1 pnpm e2e:run --host --no-build --project containers tests/kubernetes/kubernetes-executor.spec.ts -- --grep 'launches through kubeconfig'`
(1 test passed in 50.9 seconds; default text log format)
- `rtk pnpm e2e:run --host --no-build --project chromium tests/system/backend-fixture-lifecycle.spec.ts`
(8 tests passed, including early log-stream error capture without an
unhandled rejection and close-before-owned-root removal ordering)
- `rtk node --test scripts/validate-public-docs.test.mjs`
- `rtk node scripts/validate-public-docs.mjs`
- `rtk git diff --check`
@@ -0,0 +1,88 @@
---
created: 2026-09-09
status: implemented
requirements:
- REQ-EXECUTORS-K8S-PRESETS-001
- REQ-EXECUTORS-K8S-PRESETS-002
system_design:
- ../../specs/executors/system-design/kubernetes-worker-presets.md
legacy_specs:
- ../../specs/kubernetes-executor/spec.md
---
# Implementation Plan: Kubernetes Worker Presets
## Overview
Deliver three tested starting configurations through existing images and executor profiles.
First add image recipes, profile examples, and local compatibility tests.
Then verify them through the real Kind lifecycle and publish the tested scope.
This package and [startup timing](../kubernetes-startup-timing/plan.md) are the
first delivery group. [Retained compute visibility](../kubernetes-retained-compute/plan.md)
follows. There is no hard code dependency between these packages.
Execution remains sequential in the primary session unless the user authorizes delegation.
## Scope
### In scope
- Minimal, Node/pnpm, and Python build targets with explicit pins.
- Copyable PodTemplate examples and companion profile settings.
- Local image smoke tests, real Kind lifecycle tests, and operator documentation.
### Out of scope
- New UI picker, profile defaults, registry publication, release-channel changes, or Helm packaging.
- Runtime redesign, warm pools, cache controllers, or guaranteed startup reductions.
## Technical approach
Use `k8s/worker-images/Dockerfile` and `pins.env` for the prepared targets.
Use `k8s/presets/` for templates and `scripts/test-kubernetes-worker-images.sh`
for bounded validation. Existing root images supply Node, Python, Git, and bootstrap tools.
The package reuses those installations rather than maintaining another OS/toolchain base.
`internal/agent/kubernetes/worker_presets_test.go` reads the actual examples through
`ParsePodTemplate` and `ComposePod`. The existing create/profile APIs stay unchanged.
Kind coverage adds `tests/kubernetes/kubernetes-worker-presets.spec.ts` and a fixture
helper for building/loading uniquely tagged images. The managed runner owns processes and teardown.
## Tests
| Acceptance | Evidence |
| --- | --- |
| PRESETS-001.1, PRESETS-001.2 | Script `--check`, image-version output, `TestKubernetesWorkerPresetsCompose` |
| PRESETS-001.3, PRESETS-002.4 | `--smoke`, non-root write/global-install checks, `TestKubernetesWorkerPresetsPreserveRuntimeOwnership` |
| PRESETS-001.4, PRESETS-002.1 | Real configuration test and terminal workload per target |
| PRESETS-002.2, PRESETS-002.3 | Kind retention/cleanup flow and recorded platform/image evidence |
IDs in this table use the `AC-EXECUTORS-K8S-` prefix.
New tests must fail before the corresponding implementation, per TDD.
## E2E tests
`tests/kubernetes/kubernetes-worker-presets.spec.ts`, project `containers`, verifies
configuration testing, task launch, terminal work, and Stop/Resume for each actual image target.
It uses existing Kind pins and the mock agent, with no paid provider credentials.
Existing desktop/mobile configuration specs verify that manual profile entry remains usable.
No new UI composition requires another mobile specification.
## Work orders
- [x] [Task 01: Add prepared worker recipes](task-01-worker-recipes.md)
- [x] [Task 02: Verify presets through Kind](task-02-kind-presets.md)
## Verification results
Implemented. The work orders record the exact source, image, UI, and Kind
verification commands. The real worker-preset flow passed for all three targets
on Linux/amd64 through the host Kind fixture.
## Risks
- The published base includes server tooling, so minimal means minimal additions rather than a small dedicated worker image.
- Base-image npm paths can conflict with runtime `HOME` and writable volumes.
- CSI `fsGroup` behavior varies. The tested Kind configuration does not certify every cluster.
- Image downloads can increase container-shard time. Build once per fixture and retain existing runner limits.
- Immutable base pins need deliberate refreshes. No moving digest is resolved during task launch.
@@ -0,0 +1,107 @@
---
id: "01-worker-recipes"
title: "Add prepared worker recipes"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-EXECUTORS-K8S-PRESETS-001
- REQ-EXECUTORS-K8S-PRESETS-002
acceptance_criteria:
- AC-EXECUTORS-K8S-PRESETS-001.1
- AC-EXECUTORS-K8S-PRESETS-001.2
- AC-EXECUTORS-K8S-PRESETS-001.3
- AC-EXECUTORS-K8S-PRESETS-002.4
system_design:
- ../../specs/executors/system-design/kubernetes-worker-presets.md
---
# Task 01: Add Prepared Worker Recipes
## Summary
Provide reusable image targets and copyable workload inputs for three common profiles.
The existing executor remains the owner of bootstrap, auth, and workspace mounts.
## In scope
- Resolve and record an immutable published multi-platform Kandev base digest.
- Add minimal, Node/pnpm, and Python targets with writable runtime/global-install paths.
- Add source templates, profile-field examples, and the bounded build/smoke script described in the design.
- Add production-parser tests that load those actual files.
- Document build, local smoke, registry substitution, tool inventories, and storage requirements in `k8s/worker-images/README.md`.
## Out of scope
- Kind integration, image publication, new UI controls, or changes to release workflows.
- Real provider credentials, repository content in images, or host mounts.
## Acceptance
- Source inputs pass production composition and preserve all reserved runtime fields.
- Each image target passes non-root file, Git, and documented toolchain smoke checks with explicit pins.
- Smoke cleanup affects only recorded disposable test objects and reports exact image/tool versions.
## Verification
From the repository root:
```bash
rtk bash scripts/test-kubernetes-worker-images.sh --check
rtk bash scripts/test-kubernetes-worker-images.sh --smoke --platform linux/amd64
rtk git diff --check
```
From `apps/backend`:
```bash
rtk go test ./internal/agent/kubernetes -run 'TestKubernetesWorkerPresets' -count=1
```
The script and named tests are new outputs of this work order.
Write failure assertions first. A missing Docker daemon blocks smoke evidence,
not source validation. Do not mark this work order done without the required smoke result.
## Files likely touched
- `k8s/worker-images/Dockerfile`, `pins.env`, and `README.md` (new)
- `k8s/presets/minimal.yaml`, `node-pnpm.yaml`, and `python.yaml` (new)
- `scripts/test-kubernetes-worker-images.sh` (new)
- `apps/backend/internal/agent/kubernetes/worker_presets_test.go` (new)
## Dependencies
None. Required environment: Docker with Linux/amd64 image execution and registry access.
## Risks
- Image-level `/data` defaults and runtime `HOME` differ.
- Build-time downloads must use pins. Tests must not rely on a developer's npm or Python caches.
- No arm64 lifecycle claim follows from an amd64 result.
## Parallelism
`sequential`
## Inputs
- [Preset requirements](../../specs/executors/requirements/kubernetes-worker-presets.md)
- [Preset design](../../specs/executors/system-design/kubernetes-worker-presets.md)
- `Dockerfile`, `Dockerfile.universal`, `docs/images.md`
- `apps/backend/internal/agent/kubernetes/compose_test.go`
- `docs/decisions/2026-08-24-kubernetes-executor-resource-ownership.md`
## Results
Passed:
- `rtk bash scripts/test-kubernetes-worker-images.sh --check`
- `rtk bash scripts/test-kubernetes-worker-images.sh --smoke --platform linux/amd64`
(minimal `sha256:bf9586f8c189a3186886f518b0d12665a5e65a71c85a26b5bb67fd4788565379`,
node-pnpm `sha256:d3a36164c42c9dd90f2d46bc6d37fad59d8f28490566e9815a76cc2692227a92`,
Python `sha256:bf9586f8c189a3186886f518b0d12665a5e65a71c85a26b5bb67fd4788565379`
reuses the immutable minimal layer and passed the Python cache/venv smoke)
- `rtk go test ./internal/agent/kubernetes -run 'TestKubernetesWorkerPresets' -count=1`
(8 tests)
- `rtk git diff --check`
@@ -0,0 +1,123 @@
---
id: "02-kind-presets"
title: "Verify presets through Kind"
status: done
wave: 2
depends_on:
- "01-worker-recipes"
plan: "plan.md"
requirements:
- REQ-EXECUTORS-K8S-PRESETS-001
- REQ-EXECUTORS-K8S-PRESETS-002
acceptance_criteria:
- AC-EXECUTORS-K8S-PRESETS-001.4
- AC-EXECUTORS-K8S-PRESETS-002.1
- AC-EXECUTORS-K8S-PRESETS-002.2
- AC-EXECUTORS-K8S-PRESETS-002.3
system_design:
- ../../specs/executors/system-design/kubernetes-worker-presets.md
---
# Task 02: Verify Presets Through Kind
## Summary
Exercise each recipe as a real Kubernetes task environment with the current executor.
Publish instructions that name the combinations this test actually verifies.
## In scope
- Build/load the Task 01 targets through an isolated image helper in the Kind fixture.
- Drive Test Kubernetes, launch, terminal commands, a local Git operation, and per-language smoke work through the existing UI.
- Verify Stop/Resume marker retention and exact owned Pod/PVC cleanup for every target.
- Integrate the spec with existing Kubernetes container shards and attach tool/image evidence.
- Update `docs/public/k8s.md` with a focused how-to section and links to source recipes.
## Out of scope
- A new cluster harness, new release channels, provider-paid E2E, or modifications to developer clusters.
- Expanding claimed architecture support beyond actual test evidence.
## Acceptance
- All three actual image targets complete the UI/terminal workload under the restricted executor identity.
- Stop/Resume preserves each marker and terminal cleanup removes only recorded owned resources.
- Public instructions and CI evidence agree on image inputs, architecture, and tested Kubernetes version.
## Verification
From `apps`, once before the first package command in a fresh worktree:
```bash
rtk pnpm install --frozen-lockfile
```
From `apps/web`, sequentially with the managed runner:
```bash
rtk env KANDEV_E2E_CONTAINERS=1 pnpm e2e:run --project containers tests/kubernetes/kubernetes-worker-presets.spec.ts
rtk pnpm e2e:run --project chromium tests/settings/kubernetes-executor.spec.ts
rtk pnpm e2e:run --project mobile-chrome tests/settings/mobile-kubernetes-executor.spec.ts
```
From the repository root:
```bash
rtk node --test scripts/validate-public-docs.test.mjs
rtk node scripts/validate-public-docs.mjs
rtk git diff --check
```
The new Kind spec must fail against absent/incompatible preset wiring before implementation.
The runner builds current backend, helpers, and web assets. Verify test discovery
and use the existing worker budget. No overlapping container runs are allowed.
## Files likely touched
- `apps/web/e2e/tests/kubernetes/kubernetes-worker-presets.spec.ts` (new)
- `apps/web/e2e/fixtures/kubernetes-worker-images.ts` (new)
- `apps/web/e2e/fixtures/kubernetes-test-base.ts`
- `apps/web/e2e/fixtures/kubernetes-tools.ts`
- `.github/workflows/e2e-tests.yml` only if existing Kubernetes shard wiring needs the new fixture inputs
- `docs/public/k8s.md`, `k8s/worker-images/README.md`
## Dependencies
Task `01-worker-recipes`. Host Docker, the existing pinned Kind/kubectl tools,
registry access, and sufficient disk for the three targets are required.
## Risks
- A generic fixture image can hide broken recipe permissions. The test must load each actual target.
- Global npm installation needs a network-free fixture package, not a paid agent installation.
- The published page must not imply that direct `kubectl apply` of a PodTemplate starts a Kandev session.
## Parallelism
`sequential`
## Inputs
- [Preset requirements](../../specs/executors/requirements/kubernetes-worker-presets.md)
- [Preset design](../../specs/executors/system-design/kubernetes-worker-presets.md)
- `apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts`
- `apps/web/e2e/tests/settings/mobile-kubernetes-executor.spec.ts`
- `apps/web/e2e/README.md`
## Results
Passed:
- `rtk env KANDEV_E2E_CONTAINERS=1 pnpm e2e:run --host --no-build --project containers tests/kubernetes/kubernetes-worker-presets.spec.ts`
(1 test passed in 4.9 minutes; all three shipped PodTemplate files were
loaded with only the image and fixture pull policy replaced. Each actual
recipe target launched, completed terminal work, preserved Stop/Resume state,
and cleaned exact owned Pod/PVC resources. The node-pnpm target also passed
a network-free local npm global install and writable pnpm home/store checks.)
- `rtk pnpm e2e:run --host --no-build --project chromium tests/settings/kubernetes-executor.spec.ts`
(4 tests passed)
- `rtk pnpm e2e:run --host --no-build --project mobile-chrome tests/settings/mobile-kubernetes-executor.spec.ts`
(3 tests passed)
- `rtk node --test scripts/validate-public-docs.test.mjs`
- `rtk node scripts/validate-public-docs.mjs`
- `rtk git diff --check`
+15
View File
@@ -336,12 +336,27 @@ Each executor fixes one namespace and connection configuration. Each profile sup
The starter template uses `ghcr.io/kdlbs/kandev:latest`, which is a moving tag. Pin a released `ghcr.io/kdlbs/kandev:X.Y.Z` tag or immutable digest for controlled environments. A custom main-container image must match the selected Linux architecture and provide `sh`, `sleep`, `git`, Node.js, npm, the selected agent CLI or its installation prerequisites, CA trust, and any repository build tools. It must also allow the runtime user to write `/opt/kandev`, `/run/kandev/home`, and `/workspace`; set a compatible user/group or Pod `fsGroup` when the storage driver requires it.
The repository also provides copyable `minimal`, `node-pnpm`, and `python`
worker recipes in [`k8s/worker-images`](../../k8s/worker-images/README.md) plus
strict [`k8s/presets`](../../k8s/presets/) examples. Build and smoke-test a
selected target with the pinned base image, then replace its image marker with
an immutable registry digest. The current Kind evidence covers Linux `amd64`
only. These files are profile inputs, not standalone Pod manifests; Kandev
continues to own bootstrap, credentials, runtime mounts, and the workspace.
Ordinary Stop, agent restart, main-container restart, and backend restart preserve the Pod and workspace. Resume verifies the recorded name, UID, and complete ownership-label identity, creates a new local port-forward, and reconnects. Every managed create also carries a fresh 256-bit request nonce so an ambiguous API response cannot make Kandev adopt or delete a copied-label object. Archive/delete terminal cleanup or an explicit force cleanup deletes only the exact recorded Pod and, for managed storage, the exact Kandev-created PVC. A same-name object with another UID, ownership identity, or create nonce is left untouched and cleanup fails closed.
Saved executor connection settings are different from the recorded workload snapshot. Current kubeconfig/in-cluster credentials, context, and timeout are used to reach an existing session; changing them can restore or break reconnect and cleanup. Existing sessions continue to target their recorded namespace even if the saved namespace changes, and the saved namespace affects new sessions only. Current Pod template, image, platform, main container, and storage settings also affect new sessions only. If Kandev must replace a missing Pod, it uses the recorded namespace and workload snapshot rather than the edited profile.
An executor cannot be deleted or changed into or out of Kubernetes while runtime inventory still references it. Clear the sessions through normal terminal cleanup first; deleting a profile does not mutate or destroy a retained workload.
The profile's **Active sessions** card also shows retained rows after Stop.
Separate session and Pod states explain whether the recorded session is active,
retained, terminating, terminal, missing, or unknown. Main-container CPU and
memory values are requests from the verified Pod spec, not actual usage or
cost. Stop preserves a resumable Kandev-managed workspace; Archive or Delete
can remove it. Kandev does not delete an operator-owned existing claim.
See [Kubernetes](k8s.md#configure-the-kubernetes-executor) for kubeconfig and in-cluster setup, the opt-in namespaced RBAC manifest, exact ownership labels, diagnostics, template rules, and recovery guidance.
## Sprites.dev
+84
View File
@@ -259,6 +259,43 @@ A custom image does not need `agentctl`; Kandev injects the platform-matched hel
The effective runtime user must be able to write `/opt/kandev`, `/run/kandev/home`, and `/workspace`. Configure a compatible image user and volume ownership, or set an appropriate Pod `fsGroup` and verify the CSI driver's behavior. If an agent install uses npm globals, point its prefix and `PATH` at a writable location rather than assuming `/usr/local` or an image-specific home is writable. A read-only root filesystem can work only if the template and image leave every non-volume path used by installed tooling writable elsewhere. Test agent installation, Git clone, npm global installation, a terminal, and the repository's real build before promoting the image.
### Use prepared worker image recipes
The repository includes copyable worker image recipes in
[`k8s/worker-images`](../../k8s/worker-images/README.md) and strict PodTemplate
examples in [`k8s/presets`](../../k8s/presets/). They are inputs to the existing
Kubernetes executor. They do not create Pods or start sessions by themselves.
Kandev still injects `agentctl`, the launch command, credentials, runtime files,
and the selected workspace mount.
The recipes provide these starting targets:
| Target | Extra runtime |
|---|---|
| `minimal` | Published Kandev base tools, Node, npm, Python, venv, and Git |
| `node-pnpm` | The pinned pnpm version in `k8s/worker-images/pins.env` |
| `python` | The published base Python and venv support |
Build and smoke-test them with the pinned base image:
~~~bash
bash scripts/test-kubernetes-worker-images.sh --check
bash scripts/test-kubernetes-worker-images.sh --smoke --platform linux/amd64
~~~
The current recipe and Kind lifecycle evidence covers Linux `amd64` and the
Kubernetes version named in the experimental matrix. It does not establish
support for another architecture or cluster version. Build and push a selected
target to an operator-controlled registry, then replace the preset image marker
with that image's immutable digest. Keep `imagePullPolicy` appropriate for the
registry. The local `imagePullPolicy: Never` and image tags used by Kind tests
are fixture-only values.
The examples use UID 1000, writable `/workspace` paths for npm and Python
artifacts, no service-account token automount, dropped capabilities, and no
privilege escalation. Review resource requests, Pod Security admission, storage
ownership, registry access, and repository tool requirements before use.
### Choose workspace storage
| Mode | Profile fields | Lifecycle |
@@ -286,6 +323,53 @@ Kandev blocks deleting an executor, or changing an executor into or out of Kuber
Do not manually delete by name or label alone. If manual incident cleanup is unavoidable, compare the Kandev runtime inventory with namespace, name, UID, and all ownership labels first, preserve required workspace data, and record the out-of-band action.
### Read retained resource status
The **Active sessions** card shows the sanitized session inventory for the
selected executor. It includes sessions that Kandev can resume after ordinary
Stop, not only sessions whose agent process is currently running. The card
shows separate session state, Pod state, retention state, workspace mode, and
main-container CPU and memory requests.
Desktop rows use two status lines. The first line shows session and retention
badges. The second line shows Pod phase and main-container state. Use the row
disclosure to see the full labeled details. Phone cards show the same facts in
a compact group and keep the full card as the task link.
`active` means the recorded session is in an active lifecycle state.
`retained` means the verified Pending or Running Pod remains after a stopped,
completed, failed, or idle session. `terminating`, `terminal`, `missing`, and
`unknown` describe other verified or incomplete observations. These labels do
not prove agent connectivity, process activity, actual CPU or memory use, or
cost. Requests describe only the main container. They exclude sidecars and
Pod-wide resources.
Use **Stop** when the session must remain resumable. Archive or Delete can
remove a Kandev-managed Pod and workspace during terminal cleanup. An
operator-owned `existing_claim` is never deleted by Kandev. Refresh the card
after a lifecycle change because session and Pod observations can change
independently.
### Inspect Kubernetes launch timings
Kandev writes bounded Info-level records for fresh Kubernetes environment
creation. Set `KANDEV_LOG_LEVEL=info` when starting the backend, then inspect
the active backend log under `KANDEV_HOME_DIR/logs/backend-logs.log` or the
configured container log sink.
The `kubernetes.launch.stage` record reports one of `storage`, `pod_ready`,
`bootstrap`, or `agentctl_connect`. The `kubernetes.launch.completed` record
reports the total environment-call duration. Both records include an opaque
attempt ID, task/session/instance IDs, `mode: fresh`, `duration_ms`, and an
outcome of `success`, `error`, `canceled`, or `timeout`. A failed stage or
finalization site is included when known.
The total covers fresh environment creation and its rollback. It does not
measure queue wait, instance-lock wait, client construction, credential
persistence, or agent-process startup. Reconnect and replacement paths do not
emit fresh-start records. Logs never include raw requests, scripts, errors,
tokens, nonces, or credentials.
## Persistence and filesystem permissions
With `KANDEV_HOME_DIR=/data`, the PVC includes:
+11
View File
@@ -37,6 +37,9 @@ failure and recovery contracts.
- [Port collision and backend ownership safety](requirements/port-collision-safety.md)
- [SSH Executor](requirements/ssh-executor.md)
- [Remote SSH task-directory reclamation](requirements/remote-task-directory-reclamation.md)
- [Kubernetes worker presets](requirements/kubernetes-worker-presets.md)
- [Kubernetes startup timing](requirements/kubernetes-startup-timing.md)
- [Kubernetes retained compute visibility](requirements/kubernetes-retained-compute.md)
### System design
@@ -49,12 +52,20 @@ failure and recovery contracts.
- [Executor-Profile Environment Precedence System Design Part 5](system-design/executor-profile-env-precedence-05.md)
- [SSH Executor](system-design/ssh-executor.md)
- [Remote SSH task-directory reclamation](system-design/remote-task-directory-reclamation.md)
- [Kubernetes worker presets](system-design/kubernetes-worker-presets.md)
- [Kubernetes startup timing](system-design/kubernetes-startup-timing.md)
- [Kubernetes retained compute visibility](system-design/kubernetes-retained-compute.md)
## Migration record
Migration remains in progress while legacy source detail is extracted from the
canonical requirement and system-design documents above.
The [Kubernetes executor foundation](../kubernetes-executor/spec.md) remains
the current lifecycle contract. The three Kubernetes pairs own additive
presets, launch diagnostics, and retained-compute visibility. They do not replace
the foundation's resource ownership, recovery, or cleanup rules.
## Related systems
- [Agents](../agents/README.md): supplies the agent command and profile.
@@ -0,0 +1,67 @@
---
status: active
system: executors
created: 2026-09-09
owners:
- kandev
---
# Kubernetes Retained Compute Visibility Requirements
## Overview
An operator must distinguish a stopped task session from its retained Kubernetes
Pod. The executor system owns that projection. Task state and task-access policy
remain with the task system.
## Terminology
- **Retained Pod:** The exact recorded Pod still exists after a session leaves active execution.
- **Resource request:** A configured main-container CPU or memory request, not measured use or a billing amount.
- **Session state:** Kandev's recorded session lifecycle state, separate from Pod phase and container state.
## Requirements
### REQ-EXECUTORS-K8S-RETAINED-001: Truthful resource status
**Intent:** Users can recognize retained resources without confusing an idle agent
with a stopped session or a failed status lookup.
#### Acceptance criteria
- **AC-EXECUTORS-K8S-RETAINED-001.1:** Authorized session rows shall show session
state separately from Pod phase and container state.
- **AC-EXECUTORS-K8S-RETAINED-001.2:** A canceled session with an existing,
verified, non-terminal Pod shall show that its Pod remains retained after Stop.
- **AC-EXECUTORS-K8S-RETAINED-001.3:** Missing, terminating, terminal, inaccessible,
or identity-mismatched Pods shall not appear as confirmed retained compute.
- **AC-EXECUTORS-K8S-RETAINED-001.4:** Verified rows shall show the main container's
CPU and memory requests with units. Missing requests shall show as unspecified, not zero usage.
- **AC-EXECUTORS-K8S-RETAINED-001.5:** Refresh shall replace stale classifications
after Resume, deletion, or a status-access failure. Errors shall not appear as zero retained resources.
### REQ-EXECUTORS-K8S-RETAINED-002: Accessible operator guidance
**Intent:** Users can inspect the relevant task and understand the existing cleanup path.
#### Acceptance criteria
- **AC-EXECUTORS-K8S-RETAINED-002.1:** Desktop and mobile users shall see the
same status, requests, workspace mode, and retention explanation in Active sessions.
- **AC-EXECUTORS-K8S-RETAINED-002.2:** Each visible row shall provide navigation
to its authorized task. Navigation and refresh shall perform no destructive operation.
- **AC-EXECUTORS-K8S-RETAINED-002.3:** Visible guidance shall explain that Stop
preserves resources and that Archive/Delete can remove a managed workspace. Existing claims remain operator-owned.
- **AC-EXECUTORS-K8S-RETAINED-002.4:** Phone cards shall support touch navigation,
keyboard access, long identifiers, and localized text without document-level horizontal overflow.
- **AC-EXECUTORS-K8S-RETAINED-002.5:** Users shall receive no additional task
identities, Pod details, or global retained-resource totals beyond their existing access.
## Out of scope
- Automatic suspend, expiration, garbage collection, or changes to Stop/Resume/Archive/Delete.
- New Pod mutation controls, bulk cleanup, or direct archive/delete buttons in executor configuration.
- CPU usage, billing estimates, cluster capacity, or aggregate Pod scheduling calculations.
- Resource requests for sidecars, init containers, Pod overhead, or Pod-level resource budgets.
- A new global dashboard, filter system, or changes to executor mutation-impact counts.
- A claim that retained sessions contain no background processes.
@@ -0,0 +1,54 @@
---
status: active
system: executors
created: 2026-09-09
owners:
- kandev
---
# Kubernetes Startup Timing Requirements
## Overview
Operators need to locate slow Kubernetes environment starts before they choose
an optimization. The executor system owns these stage boundaries and their results.
This capability measures environment creation, not the first agent response.
## Requirements
### REQ-EXECUTORS-K8S-TIMING-001: Attributable launch durations
**Intent:** An operator can identify which completed or failed environment stage
accounts for a launch delay.
#### Acceptance criteria
- **AC-EXECUTORS-K8S-TIMING-001.1:** Each fresh environment attempt shall expose
durations for entered storage, Pod readiness, bootstrap, and agentctl-connection stages in backend diagnostic logs.
- **AC-EXECUTORS-K8S-TIMING-001.2:** Each returned attempt shall expose one
total duration and outcome. Records shall identify the attempt and its task/session without relying on log order.
- **AC-EXECUTORS-K8S-TIMING-001.3:** A failed, canceled, or timed-out stage shall
retain its elapsed duration and outcome. Stages that never start shall have no fabricated measurements.
- **AC-EXECUTORS-K8S-TIMING-001.4:** Documentation shall distinguish Pod readiness
wait from image-pull time, environment readiness from agent readiness, and fresh attempts from reconnects.
### REQ-EXECUTORS-K8S-TIMING-002: Passive diagnostics
**Intent:** Timing evidence must not change the workload or expose task secrets.
#### Acceptance criteria
- **AC-EXECUTORS-K8S-TIMING-002.1:** Timing collection shall preserve launch,
cancellation, timeout, reconnect, checkpoint, and cleanup outcomes without additional cluster requests.
- **AC-EXECUTORS-K8S-TIMING-002.2:** Timing records shall exclude credentials,
bootstrap nonces, repository URLs, scripts, environment values, Pod templates, and raw error messages.
- **AC-EXECUTORS-K8S-TIMING-002.3:** Timing durations shall remain non-negative
under wall-clock changes. Concurrent attempts shall retain separate measurements.
## Out of scope
- New UI progress stages, historical dashboards, metrics endpoints, or tracing dependencies.
- Timings for provider inference, agent installation, repository preparation, or the first response outside environment creation.
- Reconnect, Pod replacement, and container-restart timing in this first package.
- New retry, timeout, autoscaling, or warm-pool behavior.
- Guaranteed final timing records after process termination or a machine crash.
@@ -0,0 +1,64 @@
---
status: active
system: executors
created: 2026-09-09
owners:
- kandev
---
# Kubernetes Worker Presets Requirements
## Overview
Administrators need a tested starting configuration for Kubernetes task sessions.
The executor system owns workload preparation and its compatibility with session
bootstrap, credentials, and storage. These presets extend the
[Kubernetes foundation](../../kubernetes-executor/spec.md).
## Terminology
- **Preset:** A documented image recipe, PodTemplate, and workspace configuration.
- **Minimal:** The existing Kandev base image with no additional language tools.
This name does not promise a smaller image than the published base.
- **Prepared image:** An image with its documented toolchain installed before task launch.
## Requirements
### REQ-EXECUTORS-K8S-PRESETS-001: Reproducible starting configurations
**Intent:** Administrators can prepare common workloads without discovering basic
toolchain or volume-permission requirements through failed sessions.
#### Acceptance criteria
- **AC-EXECUTORS-K8S-PRESETS-001.1:** Kandev shall provide minimal, Node/pnpm,
and Python presets with exact image-build inputs and an explicit Linux architecture.
- **AC-EXECUTORS-K8S-PRESETS-001.2:** Each preset shall identify its installed
tools, CPU and memory requests, memory limit, runtime user, and workspace mode.
- **AC-EXECUTORS-K8S-PRESETS-001.3:** Each preset shall support a non-root
session that writes runtime files, credentials, and workspace files without privileged containers or host mounts.
- **AC-EXECUTORS-K8S-PRESETS-001.4:** Before launch, an administrator shall be
able to use the existing configuration test and save the preset through the existing profile flow.
### REQ-EXECUTORS-K8S-PRESETS-002: Verified workload compatibility
**Intent:** Examples describe behavior that the release's executor can perform.
#### Acceptance criteria
- **AC-EXECUTORS-K8S-PRESETS-002.1:** Each preset shall pass an actual task
launch, terminal command, Git operation, and its documented toolchain smoke workload.
- **AC-EXECUTORS-K8S-PRESETS-002.2:** The documented managed workspace shall
preserve a marker across Stop and Resume. Terminal cleanup shall remove only owned resources.
- **AC-EXECUTORS-K8S-PRESETS-002.3:** Preset instructions shall state the exact
tested architecture and distinguish image-build verification from Kubernetes lifecycle verification.
- **AC-EXECUTORS-K8S-PRESETS-002.4:** Presets shall preserve credential delivery
and runtime injection. Image layers and templates shall contain no task credentials or private repository content.
## Out of scope
- New registry publication channels, changes to Stable or Nightly releases, and a Helm chart.
- A preset picker, automatic profile creation, or changes to existing profiles.
- Warm pools, image pre-pull controllers, build caches, and startup performance guarantees.
- Provider logins or successful calls to paid agent services as a test prerequisite.
- Guaranteed compatibility with every CSI driver or restricted-cluster policy.
@@ -0,0 +1,144 @@
---
status: current
system: executors
requirements:
- REQ-EXECUTORS-K8S-RETAINED-001
- REQ-EXECUTORS-K8S-RETAINED-002
---
# Kubernetes Retained Compute Visibility System Design
## Purpose and boundaries
The [requirements](../requirements/kubernetes-retained-compute.md) extend the
existing executor session projection. The feature does not introduce a resource
inventory or infer process activity from Kubernetes readiness.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| REQ-EXECUTORS-K8S-RETAINED-001 | Backend projection and classification |
| REQ-EXECUTORS-K8S-RETAINED-002 | Permissions and responsive presentation |
## Backend projection and classification
`internal/kubernetes/sessions.go` already retrieves the task session, checks
task access, and gets the exact recorded Pod. Extend `SessionRow` at that seam.
The HTTP sessions route and `kubernetes.sessions.list` share the same projection.
Current fields retain their meaning. New fields are additive:
| Field | Meaning |
| --- | --- |
| `session_state` | Canonical known `TaskSession.State`, otherwise `unknown` |
| `retention_state` | `active`, `retained`, `terminating`, `terminal`, `missing`, or `unknown` |
| `main_container_requests.cpu` | Optional canonical Kubernetes quantity string |
| `main_container_requests.memory` | Optional canonical Kubernetes quantity string |
The request object appears only after exact identity verification and a matching
main container. It contains only CPU and memory, never a complete resource map.
The UI labels these fields as main-container requests. It does not aggregate
them or call them usage, reserved cluster capacity, or cost.
Classification follows this precedence:
1. Invalid inventory, failed access, malformed states, a missing main container,
or identity mismatch yields `unknown`.
2. An exact recorded-Pod GET returning NotFound yields `missing`.
3. A verified Pod with a deletion timestamp yields `terminating`.
4. A verified Pod in Succeeded or Failed phase yields `terminal`.
5. A Pending or Running Pod plus session `CANCELLED`, `COMPLETED`, `FAILED`, or `IDLE` yields `retained`.
6. A Pending or Running Pod plus session `CREATED`, `STARTING`, `RUNNING`, or `WAITING_FOR_INPUT` yields `active`.
7. Any remaining combination yields `unknown`.
`CANCELLED` maps to stopped-session wording. Other retained session states keep
their distinct session labels. `WAITING_FOR_INPUT` is never labeled stopped.
The term `active` denotes the recorded session category, not verified agent connectivity.
An Office `IDLE` session can still retain Kubernetes resources under the current executor policy.
Neither category establishes whether a terminal or background process exists.
Derivation uses `TaskSession.State`, not `executors_running.Status`, task workflow
columns, or container readiness. The existing endpoint enumerates inventory rows
without discarding them solely because the agent session stopped.
Keep that behavior so ordinary Stop cannot hide its retained Pod.
Task state and Pod state are separate observations. The projection does not promise
an atomic cross-system snapshot. The next refresh resolves concurrent lifecycle changes.
No new observation timestamp uses `TaskSession.UpdatedAt` as a false stopped-since clock.
## Permissions and failure behavior
Existing task authorization and identity checks remain before Pod projection.
New fields do not bypass executor/task/session filters or expand Kubernetes RBAC.
The backend performs no additional Pod GET, list, metrics, log, or Event request.
The admin-only `session-impact` response remains count-only with its existing semantics.
This feature adds no global count, including for administrators.
An access or identity error omits resource requests and uses `unknown`.
An exact NotFound uses `missing` while preserving the existing sanitized failure field.
Raw session errors and Pod spec content never enter the response.
An unavailable client remains an endpoint error rather than an empty success.
## Responsive presentation
Extend `KubernetesSession` in `lib/types/http-kubernetes.ts` and the existing API tests.
New fields are optional in TypeScript for old responses and fixture compatibility.
The backend owns classification. Frontend helpers select translated labels and
fallbacks, without recreating the state machine.
`KubernetesSessionsCard` remains the only changed product surface.
Desktop keeps the table. Each default row uses at most two status lines. The
first line shows session and retention badges. The second line shows distinct
Pod phase and main-container state values. The Pod cell also shows the workspace
mode, and a separate column shows the main-container requests. A disclosure
opens one labeled diagnostic row without changing another session row. The task
identity becomes a routing-adapter link to `/t/<encoded-task-id>`. It does not
select or resume a session automatically.
Phone presentation reuses the shipped `MobileSessionList` cards in this file and
the direct-navigation pattern from `components/kanban-with-preview.tsx`.
Each card groups Pod identity, task and session identity, compact status, requests,
workspace mode, and creation time. The status uses the same two-line hierarchy as
the desktop row. The phone card has no diagnostic disclosure.
The card body navigates to its task because there are no competing inline actions.
The whole card is a semantic link with at least a 44px touch height.
The page remains the single vertical scroll owner. Long identifiers wrap.
No drawer, extra navigation step, nested vertical scroller, or fixed footer is needed.
Desktop link density remains unchanged. Focus indicators and accessible names
identify the task and session on both viewports.
Visible card-level guidance explains Stop retention and Archive/Delete workspace
consequences. It also states that requests exclude sidecars and do not show actual use.
No direct destructive action is added. Existing task routes own those actions and confirmations.
`useKubernetesSessions` retains its 90-second poll and manual refresh behavior.
Refresh failures must remain visible. A stale prior row must not appear as current
confirmed retention. Existing executor-scope generation guards discard late responses.
Missing new fields use unknown/unspecified labels, not inferred defaults.
New copy uses `executors` translation keys in English, Portuguese, and Chinese catalogs.
Traditional Chinese derives through the existing converter. Pseudo-locale tests
cover expansion without translating state discriminants or Kubernetes quantities.
## Verification and persistence
Backend tables cover every session state, Pod phase, deletion, NotFound, access
error, identity mismatch, missing request, explicit zero request, and admission-mutated request.
Actual Pod values win over current profile values and the original template.
HTTP and WebSocket responses share field tests. Fake-client action counts prove
unauthorized rows trigger no Pod read and visible rows require one exact GET.
Component tests cover optional fields, distinct labels, links, loading, and errors.
Desktop and mobile E2E extend the existing Kubernetes configuration specs.
They verify navigation, safe guidance, responsive geometry, and post-refresh changes.
A Kind lifecycle scenario proves Stop retains the Pod and refresh shows it,
then Resume clears the retained category without replacing the Pod.
No schema, lifecycle, poller, feature-toggle, or credential change is required.
Public documentation changes describe the new fields and retained-resource wording.
## Related decisions and delivery
- [Kubernetes resource ownership](../../../decisions/2026-08-24-kubernetes-executor-resource-ownership.md)
- [Kubernetes foundation](../../kubernetes-executor/spec.md)
- [Retained compute plan](../../../plans/kubernetes-retained-compute/plan.md)
@@ -0,0 +1,102 @@
---
status: current
system: executors
requirements:
- REQ-EXECUTORS-K8S-TIMING-001
- REQ-EXECUTORS-K8S-TIMING-002
---
# Kubernetes Startup Timing System Design
## Purpose and boundaries
The [requirements](../requirements/kubernetes-startup-timing.md) cover only
`KubernetesExecutor.createFresh` in `internal/agent/runtime/lifecycle`.
`KubernetesPreparer.Prepare` currently emits a short progress step before actual
resource creation. Its duration is not an environment-start measurement.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| REQ-EXECUTORS-K8S-TIMING-001 | Measurement contract |
| REQ-EXECUTORS-K8S-TIMING-002 | Passive collection and failures |
## Measurement contract
One local recorder belongs to one `createFresh` call. A new opaque attempt ID
distinguishes retries that reuse a session or instance ID. Existing task, session,
and instance IDs provide correlation with lifecycle evidence.
| Stage | Start | End |
| --- | --- | --- |
| `storage` | Before `launch.provisionWorkspace` | After workspace verification/provisioning and its checkpoint |
| `pod_ready` | Before `launch.createRunningPod` | After admitted main-container-running observation and checkpoint |
| `bootstrap` | Before nonce generation and binary resolution | After `bootstrapPod` returns |
| `agentctl_connect` | Before `connectNewAgentctl` | After its health/nonce handshake and forward setup return |
`pod_ready` includes scheduling, image pulls, init work, and storage binding waits
that occur inside that call. It does not measure those substeps independently.
`storage` can complete before a deferred-binding PVC becomes Bound.
The total begins at `createFresh` entry and ends at its final return.
It includes `launch.complete`, bookkeeping, and failure rollback.
Stage sums therefore need not equal the total. Queue wait, instance-lock wait,
client construction, manager secret persistence, and agent-process startup are outside this total.
The summary means environment-call completion, not durable session readiness.
Proposed Info-level records are `kubernetes.launch.stage` and
`kubernetes.launch.completed`. Both use `attempt_id`, `instance_id`, `task_id`,
`session_id`, `mode: fresh`, `duration_ms`, and `outcome`.
Stage records add `stage`. The summary adds `failed_stage` only when an entered
stage failed. A post-stage `launch.complete` failure uses `failure_site: finalize`.
An identity/setup failure before stages uses `failure_site: initialize`.
These fields are a bounded diagnostic vocabulary, not a public API.
Outcomes are `success`, `error`, `canceled`, and `timeout`.
Context outcomes use `errors.Is` on the returned error.
A later context cancellation does not relabel a successful result.
No record contains `err.Error()` or serialized request metadata.
## Passive collection and failures
Use the existing logger and Go monotonic durations (`time.Now`/`time.Since`).
A narrow clock seam or `testing/synctest` supports deterministic duration tests.
The helper stays local to the Kubernetes lifecycle package. No shared executor
interface, global registry, timer goroutine, or telemetry queue is needed.
A stage emits once on return, including errors. Skipped stages emit nothing.
The final summary defer is registered before rollback so it observes the final
returned error after rollback. It preserves the original failed-stage result.
Rollback errors can change the summary outcome but do not overwrite stage evidence.
The helper never intercepts or replaces the original return error.
Existing reconnect and replacement paths bypass this recorder.
Their successful behavior does not emit a misleading fresh-start measurement.
Each attempt emits at most four stage records and one summary.
Existing log filtering and retention govern storage. Info records are available
in backend file logs under normal configuration, not necessarily `kubectl logs`.
## Verification
`executor_kubernetes_timing_test.go` drives real fresh-launch wiring with the
existing fake clients and a Zap observer. Assertions cover phase order, bounded
record counts, concurrent attempts, and failures at every stage and finalization.
Sentinel credentials, nonces, scripts, and hostile error text must not appear.
Fake-client action counts and existing lifecycle tests prove no new API operations.
Reconnect and missing-Pod replacement must emit no `mode: fresh` records.
The existing Kind launch scenario supplies a real backend-log smoke assertion.
It checks correlation and entered-stage completeness without timing thresholds.
Performance comparisons remain observations on a named machine and image.
## Persistence and related contracts
No timing fields enter `executors_running`, task state, or browser payloads.
No schema migration or new runtime configuration is required.
The [resource ownership ADR](../../../decisions/2026-08-24-kubernetes-executor-resource-ownership.md)
continues to govern checkpoint and rollback behavior.
## Delivery
See the [startup timing plan](../../../plans/kubernetes-startup-timing/plan.md).
@@ -0,0 +1,116 @@
---
status: current
system: executors
requirements:
- REQ-EXECUTORS-K8S-PRESETS-001
- REQ-EXECUTORS-K8S-PRESETS-002
---
# Kubernetes Worker Presets System Design
## Purpose and boundaries
The executor system owns the image-to-bootstrap compatibility contract.
The [requirements](../requirements/kubernetes-worker-presets.md) add tested
examples to the existing raw PodTemplate flow. They require no executor API change.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| REQ-EXECUTORS-K8S-PRESETS-001 | Image recipes and profile examples |
| REQ-EXECUTORS-K8S-PRESETS-002 | Verification and maintenance |
## Image recipes and profile examples
New source lives under `k8s/worker-images/` and `k8s/presets/`.
One Dockerfile exposes `minimal`, `node-pnpm`, and `python` targets.
It derives from a published Kandev base image by immutable multi-platform digest.
The current root `Dockerfile` already supplies Node, npm, Python, venv, Git,
and the POSIX bootstrap tools. The Python target reuses those installations.
Only the Node/pnpm target adds pnpm, using the version in `apps/package.json`.
`k8s/worker-images/pins.env` records the chosen base digest and pnpm version.
Implementation resolves a published digest and records tool versions from built
images. Planning does not invent a digest or promise byte-identical rebuilds.
The recipe rejects absent pins and moving base tags. It ends with `USER 1000`.
No automatic publishing or root Dockerfile modification is required.
Each preset has one strict `v1/PodTemplate` example and a companion description
of profile fields. A template is an input to Kandev, not a directly runnable Pod.
Templates name `kandev-agent`, use `runAsNonRoot`, UID 1000, `fsGroup: 1000`,
disabled token automount, dropped capabilities, and disabled privilege escalation.
The templates do not set Kandev-owned command, mounts, `HOME`, ports, or restart policy.
Initial resource examples request 250m CPU and 512Mi memory with a 2Gi memory limit.
These values are starting settings, not measured workload requirements.
The default workspace is a 10Gi managed PVC with `ReadWriteOnce`.
The instructions explain the existing-claim and empty-directory alternatives.
No security claim depends only on `fsGroup` support across all storage drivers.
Examples keep npm globals and caches in writable session paths.
The existing image's `/data` defaults require explicit examination because
Kandev replaces `HOME` with `/run/kandev/home` and mounts the workspace at `/workspace`.
The Node example sets `NPM_CONFIG_PREFIX` and the corresponding `PATH` to a
writable directory under `/workspace`. These settings must survive the documented bootstrap.
Python uses a workspace venv rather than writes to the system interpreter.
Templates use an explicit image substitution marker until the operator builds
and publishes to their registry. The validation script accepts that marker only
in source examples. Resolved inputs must contain an immutable image reference.
Kind tests substitute a locally built image tag and `imagePullPolicy: Never`.
That fixture-only substitution is not the documented production policy.
## Runtime integration
`ParsePodTemplate`, `ComposePod`, admission validation, and the existing streaming
test remain the runtime gates. `KubernetesExecutor.bootstrapPod` continues to
upload the backend-selected helper and credentials through exec.
Image recipes do not replace the helper handshake or execute the server entrypoint.
Profile imports remain manual through the current create/edit flow on both viewports.
## Verification and maintenance
`scripts/test-kubernetes-worker-images.sh` is a proposed bounded build/smoke entry point.
`--check` verifies pins, targets, and source templates without a Docker daemon.
`--smoke --platform linux/amd64` builds the three targets and runs tool/version,
writable-path, local Git, npm-global, pnpm offline-project, and Python-venv tests.
It records image IDs and tool versions. All smoke data uses disposable volumes.
Backend `worker_presets_test.go` loads the actual example templates and passes
them through production parsing/composition. It asserts reserved-field compatibility.
No duplicate YAML parser or Kubernetes policy implementation is introduced.
The new `tests/kubernetes/kubernetes-worker-presets.spec.ts` uses the existing
Kind fixture, restricted executor identity, backend, and mock agent.
It drives Test Kubernetes and a real task/terminal for each preset.
It verifies a local Git operation and a small language workload through the terminal.
One parameterized stop/resume/cleanup scenario proves storage behavior for every target.
Tests load the actual built targets, not the fixture's generic Ubuntu image.
Initial required lifecycle evidence is Linux/amd64 on the existing Kind version.
An arm64 build/smoke result does not establish an arm64 Kubernetes lifecycle result.
Public guidance lists untested combinations explicitly. Existing profile support
for arm64 remains available independently of these examples.
The container E2E workflow includes the new spec through its existing Kubernetes
shard detection. Image build cost is paid once per fixture worker with unique tags.
Cleanup tracks only images, containers, volumes, and cluster names created by that fixture.
There is no daemon-wide prune or shared-cache mutation.
## Failure, security, and persistence
A recipe failure produces no profile or cluster mutation.
Failed test launches retain existing identity-checked cleanup behavior.
Samples contain no credentials, Git remotes, host mounts, or Docker sockets.
The operator remains responsible for registry access, storage ownership, and cluster admission.
No database changes or new runtime configuration keys are required.
## Related decisions
- [Kubernetes resource ownership](../../../decisions/2026-08-24-kubernetes-executor-resource-ownership.md)
- [Current Kubernetes foundation](../../kubernetes-executor/spec.md)
## Delivery
See the [worker presets plan](../../../plans/kubernetes-worker-presets/plan.md).
+27
View File
@@ -0,0 +1,27 @@
apiVersion: v1
kind: PodTemplate
template:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: kandev-agent
image: ghcr.io/kdlbs/kandev-worker@sha256:REPLACE_WITH_MINIMAL_DIGEST
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 2Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
+34
View File
@@ -0,0 +1,34 @@
apiVersion: v1
kind: PodTemplate
template:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: kandev-agent
image: ghcr.io/kdlbs/kandev-worker@sha256:REPLACE_WITH_NODE_PNPM_DIGEST
imagePullPolicy: IfNotPresent
env:
- name: NPM_CONFIG_PREFIX
value: /workspace/.npm-global
- name: PNPM_HOME
value: /workspace/.pnpm
- name: PATH
value: /workspace/.pnpm:/workspace/.npm-global/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 2Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
+30
View File
@@ -0,0 +1,30 @@
apiVersion: v1
kind: PodTemplate
template:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: kandev-agent
image: ghcr.io/kdlbs/kandev-worker@sha256:REPLACE_WITH_PYTHON_DIGEST
imagePullPolicy: IfNotPresent
env:
- name: PIP_CACHE_DIR
value: /workspace/.cache/pip
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 2Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
+37
View File
@@ -0,0 +1,37 @@
# Kandev worker image recipes.
#
# These images are task-session inputs, not control-plane images. Kandev still
# owns the command, helper, credentials, runtime volume, and workspace mount.
ARG BASE_IMAGE=ghcr.io/kdlbs/kandev:0.93.0@sha256:1a3c98e62bb3ab20141695b6a6b6fdcd058480dd2ef42a89277724b95afc6884
FROM ${BASE_IMAGE} AS base
USER root
# Keep runtime installs and caches on the Kandev workspace. The executor mounts
# /workspace after image start, so these directories are created in the image
# only to document the expected ownership and to support image smoke tests.
RUN mkdir -p /workspace/.npm-global /workspace/.pnpm /workspace/.pnpm-store /workspace/.cache/pip \
&& chown -R 1000:1000 /workspace
ENV NPM_CONFIG_PREFIX=/workspace/.npm-global \
PNPM_HOME=/workspace/.pnpm \
PIP_CACHE_DIR=/workspace/.cache/pip \
PATH=/workspace/.pnpm:/workspace/.npm-global/bin:$PATH
FROM base AS minimal
USER 1000
FROM base AS node-pnpm
ARG PNPM_VERSION=9.15.9
USER root
RUN npm install --global --prefix /usr/local "pnpm@${PNPM_VERSION}" \
&& pnpm --version
USER 1000
FROM base AS python
USER 1000
+76
View File
@@ -0,0 +1,76 @@
# Kubernetes worker images
These recipes prepare the main container for a Kandev Kubernetes task session.
They do not start a session by themselves. Kandev adds the command, agentctl
helper, credentials, runtime files, and workspace mounts during launch.
## Targets and pins
Build the targets from the repository root with the immutable base and pnpm
inputs in pins.env.
| Target | Added or verified tools | Runtime paths |
|---|---|---|
| minimal | Node 24, npm, Python 3, venv, Git, and the POSIX tools from the published Kandev base | npm prefix /workspace/.npm-global, pnpm home /workspace/.pnpm, Python cache /workspace/.cache/pip |
| node-pnpm | pnpm 9.15.9 installed under /usr/local | npm globals use /workspace/.npm-global; pnpm uses /workspace/.pnpm and /workspace/.pnpm-store |
| python | Python 3 and venv from the published Kandev base | Python virtual environments and pip cache use /workspace |
The current pin is the multi-platform index for
ghcr.io/kdlbs/kandev:0.93.0:
sha256:1a3c98e62bb3ab20141695b6a6b6fdcd058480dd2ef42a89277724b95afc6884.
The image build and smoke command below is explicitly linux/amd64. A build
for another architecture does not establish Kubernetes lifecycle support for it.
~~~bash
bash scripts/test-kubernetes-worker-images.sh --check
bash scripts/test-kubernetes-worker-images.sh --smoke --platform linux/amd64
~~~
The smoke command builds each target with a unique local tag, runs as UID 1000,
uses a disposable Docker volume, performs a local Git commit, and checks the
target-specific toolchain. It removes only those tags and volumes when it exits.
It does not publish images.
## Use a target in a profile
Copy one of the strict PodTemplate examples from k8s/presets into the existing
Kubernetes executor profile. Replace its image marker with the immutable digest
of the image you built and pushed to your registry. Keep main_container
kandev-agent and set the profile platform to linux/amd64 for the evidence
covered here.
Each example starts with these profile fields:
~~~text
platform: linux/amd64
main_container: kandev-agent
workspace.mode: managed_pvc
workspace.size: 10Gi
workspace.access_modes: ["ReadWriteOnce"]
~~~
Use workspace.mode: empty_dir for disposable work, or
workspace.mode: existing_claim with an administrator-provisioned claim. The
managed PVC preserves the workspace across ordinary Stop and Resume. Archive or
Delete can remove a managed workspace through the existing task lifecycle.
The templates request 250m CPU and 512Mi memory, and limit memory to 2Gi.
These are starting values, not usage measurements. They run the main container
as UID 1000 with a non-root Pod security context, disable service-account token
automount, drop all Linux capabilities, and disable privilege escalation.
The templates deliberately omit Kandev-owned fields. Do not add commands,
arguments, working directories, reserved mounts, ports, restart policy, HOME,
or KANDEV_* environment keys. The existing profile validator rejects those
conflicts before a cluster write.
## Registry substitution
Build and push each target with your registry's immutable digest, then replace
only the image value in the selected PodTemplate. For a local Kind test, the
test fixture may use imagePullPolicy: Never and a locally loaded tag. That
fixture-only tag is not a production pin.
The recipes contain no credentials, repository content, host paths, or Docker
socket mounts. Registry access, Pod admission, storage policy, and outbound
connectivity remain administrator responsibilities.
+5
View File
@@ -0,0 +1,5 @@
# Inputs for the reproducible worker recipes. Keep the multi-platform index
# digest here. The initial lifecycle evidence is linux/amd64.
BASE_IMAGE=ghcr.io/kdlbs/kandev:0.93.0@sha256:1a3c98e62bb3ab20141695b6a6b6fdcd058480dd2ef42a89277724b95afc6884
PNPM_VERSION=9.15.9
PLATFORM=linux/amd64
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(dirname "${BASH_SOURCE[0]}")
repo_root=$(cd "$script_dir/.." && pwd)
image_dir="$repo_root/k8s/worker-images"
pin_file="$image_dir/pins.env"
template_dir="$repo_root/k8s/presets"
targets=(minimal node-pnpm python)
mode=check
platform=
usage() {
cat <<'EOF'
Usage: scripts/test-kubernetes-worker-images.sh [--check | --smoke] [--platform PLATFORM]
--check validate pins, targets, and source PodTemplates without Docker
--smoke build each target and run bounded non-root toolchain checks
EOF
}
die() {
echo "worker image validation: $*" >&2
exit 1
}
while (($# > 0)); do
case "$1" in
--check) mode=check ;;
--smoke) mode=smoke ;;
--platform)
shift
(($# > 0)) || die "--platform requires a value"
platform=$1
;;
--platform=*) platform=${1#--platform=} ;;
-h|--help)
usage
exit 0
;;
*) die "unknown argument $1" ;;
esac
shift
done
[[ -f "$pin_file" ]] || die "missing $pin_file"
[[ -f "$image_dir/Dockerfile" ]] || die "missing worker Dockerfile"
# shellcheck disable=SC1090
source "$pin_file"
platform=${platform:-${PLATFORM:-}}
if [[ -z "${BASE_IMAGE:-}" || ! "$BASE_IMAGE" =~ @sha256:[0-9a-f]{64}$ ]]; then
die "BASE_IMAGE must end in an immutable sha256 digest"
fi
if [[ "$BASE_IMAGE" == *":latest"* ]]; then die "BASE_IMAGE must not use latest"; fi
if [[ -z "${PNPM_VERSION:-}" || ! "$PNPM_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
die "PNPM_VERSION must be a pinned semantic version"
fi
if [[ "$mode" == smoke && "$platform" != linux/amd64 ]]; then
die "this validation currently supports linux/amd64 only"
fi
assert_file_contains() {
local file=$1
local pattern=$2
grep -Fq -- "$pattern" "$file" || die "$file does not contain $pattern"
}
for target in "${targets[@]}"; do
assert_file_contains "$image_dir/Dockerfile" "FROM base AS $target"
template="$template_dir/$target.yaml"
[[ -f "$template" ]] || die "missing $template"
assert_file_contains "$template" "apiVersion: v1"
assert_file_contains "$template" "kind: PodTemplate"
assert_file_contains "$template" "name: kandev-agent"
assert_file_contains "$template" "image: ghcr.io/kdlbs/kandev-worker@sha256:REPLACE_WITH_"
assert_file_contains "$template" "runAsNonRoot: true"
assert_file_contains "$template" "runAsUser: 1000"
assert_file_contains "$template" "fsGroup: 1000"
assert_file_contains "$template" "automountServiceAccountToken: false"
assert_file_contains "$template" "allowPrivilegeEscalation: false"
assert_file_contains "$template" "- ALL"
assert_file_contains "$template" "cpu: 250m"
assert_file_contains "$template" "memory: 512Mi"
assert_file_contains "$template" "memory: 2Gi"
if grep -nE '^[[:space:]]+(-[[:space:]]+)?(command|args|workingDir|restartPolicy|volumeMounts|ports):' "$template" \
|| grep -nE '^[[:space:]]+-[[:space:]]+name:[[:space:]]*(HOME|KANDEV_[^[:space:]]*)($|[[:space:]])' "$template"; then
die "$template contains a Kandev-owned field"
fi
done
if [[ "$mode" == check ]]; then
echo "worker image source check passed: ${targets[*]} ($platform)"
exit 0
fi
command -v docker >/dev/null 2>&1 || die "Docker is required for --smoke"
docker info >/dev/null 2>&1 || die "Docker daemon is not available for --smoke"
declare -a created_images=()
declare -a created_volumes=()
cleanup() {
set +e
for volume in "${created_volumes[@]}"; do docker volume rm "$volume" >/dev/null 2>&1; done
for image in "${created_images[@]}"; do docker image rm --force "$image" >/dev/null 2>&1; done
}
trap cleanup EXIT
for target in "${targets[@]}"; do
tag="kandev-worker-smoke:${target}-${BASHPID}"
volume="kandev-worker-smoke-${target}-${BASHPID}"
created_images+=("$tag")
created_volumes+=("$volume")
docker build \
--platform "$platform" \
--build-arg "BASE_IMAGE=$BASE_IMAGE" \
--build-arg "PNPM_VERSION=$PNPM_VERSION" \
--target "$target" \
--tag "$tag" \
--file "$image_dir/Dockerfile" \
"$repo_root" >/dev/null
docker volume create "$volume" >/dev/null
docker run --rm --network=none --platform "$platform" --user 1000:1000 \
--env "PRESET=$target" --mount "type=volume,src=$volume,dst=/workspace" \
--entrypoint /bin/sh "$tag" -ceu '
test "$(id -u)" = 1000
mkdir -p /workspace/.runtime /workspace/.cache/pip
touch /workspace/.runtime/marker
node --version
npm --version
python3 --version
git --version
git init /workspace/git-smoke >/dev/null
git -C /workspace/git-smoke config user.email smoke@example.invalid
git -C /workspace/git-smoke config user.name kandev-smoke
printf "worker smoke\n" > /workspace/git-smoke/README.md
git -C /workspace/git-smoke add README.md
git -C /workspace/git-smoke commit -m smoke >/dev/null
case "$PRESET" in
node-pnpm)
pnpm --version
mkdir -p /tmp/smoke-package
printf "{\"name\":\"kandev-smoke-package\",\"version\":\"1.0.0\",\"bin\":{\"kandev-smoke\":\"index.js\"}}\n" > /tmp/smoke-package/package.json
printf "#!/bin/sh\nexit 0\n" > /tmp/smoke-package/index.js
chmod +x /tmp/smoke-package/index.js
npm install --global --prefix /workspace/.npm-global --no-audit --no-fund /tmp/smoke-package >/dev/null
test -x /workspace/.npm-global/bin/kandev-smoke
mkdir -p /workspace/pnpm-project
printf "{\"name\":\"kandev-pnpm-smoke\",\"private\":true}\n" > /workspace/pnpm-project/package.json
pnpm --dir /workspace/pnpm-project install --offline --store-dir /workspace/.pnpm-store >/dev/null
;;
python)
python3 -m venv /workspace/.venv
/workspace/.venv/bin/python -c "import sys; assert sys.version_info.major == 3"
test -d /workspace/.cache/pip
;;
minimal)
test -x "$(command -v git)"
;;
esac
'
image_id=$(docker image inspect --format '{{.Id}}' "$tag")
echo "worker image smoke passed: target=$target platform=$platform image_id=$image_id"
done