diff --git a/apps/backend/internal/agent/kubernetes/worker_presets_test.go b/apps/backend/internal/agent/kubernetes/worker_presets_test.go new file mode 100644 index 0000000000..411e1183db --- /dev/null +++ b/apps/backend/internal/agent/kubernetes/worker_presets_test.go @@ -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 +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go index 30122e4b7b..0a1403a5ca 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes.go @@ -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 { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing.go b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing.go new file mode 100644 index 0000000000..d5d6015d29 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing.go @@ -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 + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing_test.go new file mode 100644 index 0000000000..eb5d500baf --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_kubernetes_timing_test.go @@ -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 +} diff --git a/apps/backend/internal/kubernetes/sessions.go b/apps/backend/internal/kubernetes/sessions.go index cc599ac3e0..5907cfca55 100644 --- a/apps/backend/internal/kubernetes/sessions.go +++ b/apps/backend/internal/kubernetes/sessions.go @@ -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) diff --git a/apps/backend/internal/kubernetes/sessions_namespace_test.go b/apps/backend/internal/kubernetes/sessions_namespace_test.go index aed0fc5b28..db2cf6d744 100644 --- a/apps/backend/internal/kubernetes/sessions_namespace_test.go +++ b/apps/backend/internal/kubernetes/sessions_namespace_test.go @@ -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) diff --git a/apps/backend/internal/kubernetes/sessions_retention.go b/apps/backend/internal/kubernetes/sessions_retention.go new file mode 100644 index 0000000000..8de09b62b4 --- /dev/null +++ b/apps/backend/internal/kubernetes/sessions_retention.go @@ -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 + } +} diff --git a/apps/backend/internal/kubernetes/sessions_retention_test.go b/apps/backend/internal/kubernetes/sessions_retention_test.go new file mode 100644 index 0000000000..349f74bbb6 --- /dev/null +++ b/apps/backend/internal/kubernetes/sessions_retention_test.go @@ -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 + }) +} diff --git a/apps/backend/internal/kubernetes/sessions_test.go b/apps/backend/internal/kubernetes/sessions_test.go index 5e07a05cf7..f9cc6099b4 100644 --- a/apps/backend/internal/kubernetes/sessions_test.go +++ b/apps/backend/internal/kubernetes/sessions_test.go @@ -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{{ diff --git a/apps/web/components/settings/kubernetes-session-lists.tsx b/apps/web/components/settings/kubernetes-session-lists.tsx new file mode 100644 index 0000000000..d1f3e87d08 --- /dev/null +++ b/apps/web/components/settings/kubernetes-session-lists.tsx @@ -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 ( +
+ {sessions.map((session) => ( + + + + + + + ))} +
+ ); +} + +export function DesktopSessionTable({ sessions }: { sessions: KubernetesSession[] }) { + const { t } = useTranslation(); + const [expandedSessionId, setExpandedSessionId] = useState(null); + return ( +
+ + + + {t("executors:task")} + {t("executors:session")} + {t("executors:kubernetesPod")} + {t("executors:status")} + {t("executors:kubernetesRequests")} + {t("executors:kubernetesCreated")} + + + + {sessions.map((session) => { + const expanded = expandedSessionId === session.session_id; + return ( + + + + + {shortId(session.task_id)} + + + + {shortId(session.session_id)} + + +

{session.pod_name || "-"}

+

+ {workspaceLabel(session.workspace_kind, t)} +

+
+ +
+ + +
+
+ + + + + {formatCreatedAt(session.created_at)} + +
+ {expanded && ( + + + + + + )} +
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/components/settings/kubernetes-session-presentation.tsx b/apps/web/components/settings/kubernetes-session-presentation.tsx new file mode 100644 index 0000000000..213b720769 --- /dev/null +++ b/apps/web/components/settings/kubernetes-session-presentation.tsx @@ -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 ( +
+

{session.pod_name || "-"}

+ + +
+ ); +} + +function SessionIdentityValue({ label, value }: { label: string; value: string }) { + return ( +

+ {label}: + {value} +

+ ); +} + +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 ( +
+
+ + {sessionStateLabel(sessionState, t)} + + + {retentionLabel(retentionState, t)} + +
+
+ + {t("executors:kubernetesPodCompactValue", { value: podStatusLabel(podPhase, t) })} + + + + {t("executors:kubernetesContainerCompactValue", { + value: podStatusLabel(mainContainerState, t), + })} + + {session.restarts > 0 && ( + <> + + + + )} +
+
+ ); +} + +export function SessionRuntimeDetails({ session }: { session: KubernetesSession }) { + const { t } = useTranslation(); + const retentionState = normalizedState(session.retention_state); + const podPhase = podPhaseValue(session); + const mainContainerState = mainContainerStateValue(session); + return ( +
+

+ {t("executors:kubernetesSessionStateValue", { + value: sessionStateLabel(session.session_state, t), + })} +

+

+ {t("executors:kubernetesRetentionStateValue", { + value: retentionLabel(retentionState, t), + })} +

+

{t("executors:kubernetesPodStateValue", { value: podStatusLabel(podPhase, t) })}

+

+ {t("executors:kubernetesContainerStateValue", { + value: podStatusLabel(mainContainerState, t), + })} +

+

{t("executors:kubernetesRestarts", { count: session.restarts })}

+

+ {t("executors:kubernetesWorkspaceValue", { + value: workspaceLabel(session.workspace_kind, t), + })} +

+

{t("executors:kubernetesCreatedValue", { value: formatCreatedAt(session.created_at) })}

+ + +
+ ); +} + +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 ( +

+ {showLabel && {t("executors:kubernetesMainContainerRequests")}: } + {values.length > 0 ? values.join(" · ") : t("executors:kubernetesRequestsUnspecifiedValue")} +

+ ); +} + +function RestartCount({ count }: { count: number }) { + const { t } = useTranslation(); + return {t("executors:kubernetesRestarts", { count })}; +} + +export function SessionDetails({ session }: { session: KubernetesSession }) { + const { t } = useTranslation(); + return ( +
+

+ {t("executors:kubernetesWorkspaceValue", { + value: workspaceLabel(session.workspace_kind, t), + })} +

+

{t("executors:kubernetesCreatedValue", { value: formatCreatedAt(session.created_at) })}

+ +
+ ); +} + +export function SessionFailureReason({ session }: { session: KubernetesSession }) { + return session.failure_reason ? ( +

{session.failure_reason}

+ ) : null; +} diff --git a/apps/web/components/settings/kubernetes-session-utils.ts b/apps/web/components/settings/kubernetes-session-utils.ts new file mode 100644 index 0000000000..6d650f288a --- /dev/null +++ b/apps/web/components/settings/kubernetes-session-utils.ts @@ -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 = { + 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 = { + active: "executors:kubernetesRetentionActive", + retained: "executors:kubernetesRetentionRetained", + terminating: "executors:kubernetesRetentionTerminating", + terminal: "executors:kubernetesRetentionTerminal", + missing: "executors:kubernetesRetentionMissing", + unknown: "executors:kubernetesRetentionUnknown", +}; + +const SESSION_STATE_LABEL_KEYS: Record = { + 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 = { + 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); +} diff --git a/apps/web/components/settings/kubernetes-sessions-card.test.tsx b/apps/web/components/settings/kubernetes-sessions-card.test.tsx new file mode 100644 index 0000000000..996c5d9c3c --- /dev/null +++ b/apps/web/components/settings/kubernetes-sessions-card.test.tsx @@ -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(); + + 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(); + 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(); + 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(); + 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["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 () => []), + }; +} diff --git a/apps/web/components/settings/kubernetes-sessions-card.tsx b/apps/web/components/settings/kubernetes-sessions-card.tsx index c4f4995cb9..9ac72d2efa 100644 --- a/apps/web/components/settings/kubernetes-sessions-card.tsx +++ b/apps/web/components/settings/kubernetes-sessions-card.tsx @@ -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 = { - running: "executors:kubernetesStatusRunning", - waiting: "executors:kubernetesStatusWaiting", - terminated: "executors:kubernetesStatusTerminated", - unknown: "executors:kubernetesStatusUnknown", - pending: "executors:kubernetesStatusPending", - succeeded: "executors:kubernetesStatusSucceeded", - failed: "executors:kubernetesStatusFailed", -}; type KubernetesSessionsState = ReturnType; @@ -54,6 +40,7 @@ export function KubernetesSessionsCard({ state }: { state: KubernetesSessionsSta } /> + {!state.error && } {Boolean(state.error) && (

{errorMessage}

)} @@ -74,158 +61,14 @@ export function KubernetesSessionsCard({ state }: { state: KubernetesSessionsSta ); } -function MobileSessionList({ sessions }: { sessions: KubernetesSession[] }) { - return ( -
- {sessions.map((session) => ( -
- - - -
- ))} -
- ); -} - -function DesktopSessionTable({ sessions }: { sessions: KubernetesSession[] }) { +function SessionGuidance() { const { t } = useTranslation(); return ( -
- - - - {t("executors:task")} - {t("executors:session")} - {t("executors:kubernetesPod")} - {t("executors:status")} - {t("executors:kubernetesWorkspace")} - {t("executors:kubernetesCreated")} - - - - {sessions.map((session) => ( - - {shortId(session.task_id)} - {shortId(session.session_id)} - - {session.pod_name || "-"} - - -
- - -
-
- {workspaceLabel(session.workspace_kind)} - - {formatCreatedAt(session.created_at)} - -
- ))} -
-
-
+

+ {t("executors:kubernetesActiveSessionsGuidance")} +

); } - -function SessionIdentity({ session }: { session: KubernetesSession }) { - const { t } = useTranslation(); - return ( -
-
-

{t("executors:kubernetesPod")}

-

{session.pod_name || "-"}

-
-
- - -
-
- ); -} - -function SessionIdentityValue({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ); -} - -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 ( -
- {statusLabel(status)} - {session.restarts > 0 && } -
- ); -} - -function RestartCount({ count }: { count: number }) { - const { t } = useTranslation(); - return ( - - {t("executors:kubernetesRestarts", { count })} - - ); -} - -function SessionDetails({ session }: { session: KubernetesSession }) { - const { t } = useTranslation(); - return ( -
-

- {t("executors:kubernetesWorkspaceValue", { - value: workspaceLabel(session.workspace_kind), - })} -

-

{t("executors:kubernetesCreatedValue", { value: formatCreatedAt(session.created_at) })}

- -
- ); -} - -function SessionFailureReason({ session }: { session: KubernetesSession }) { - return session.failure_reason ? ( -

{session.failure_reason}

- ) : null; -} - -function workspaceLabel(value?: string): string { - const keys: Record = { - 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); -} diff --git a/apps/web/components/settings/kubernetes-settings-cards.test.tsx b/apps/web/components/settings/kubernetes-settings-cards.test.tsx index de9aec0041..a66ea96097 100644 --- a/apps/web/components/settings/kubernetes-settings-cards.test.tsx +++ b/apps/web/components/settings/kubernetes-settings-cards.test.tsx @@ -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(); + + 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(); - 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, diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts index ebeda5e067..8d2775f756 100644 --- a/apps/web/e2e/fixtures/backend.ts +++ b/apps/web/e2e/fixtures/backend.ts @@ -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; +}; + +type LogFileStream = Pick; + +export function observeLogFile(logFile: LogFileStream): () => Promise { + let logFileError: Error | undefined; + const logFileClosed = new Promise((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( tmpDir: string, - run: (registerProcess: (proc: ChildProcess) => void) => Promise, + run: (registerProcess: (proc: BackendProcess) => void) => Promise, lifecycle: BackendFixtureLifecycle = {}, ): Promise { 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( } } + 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, 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({ 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({ 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({ 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({ // 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({ frontendPort, frontendUrl, tmpDir, + logPath: backendLogPath, pid: () => backendProc?.pid, restart, ensureReady, diff --git a/apps/web/e2e/fixtures/kubernetes-test-base.ts b/apps/web/e2e/fixtures/kubernetes-test-base.ts index add3a8ec00..95b9c2cb9e 100644 --- a/apps/web/e2e/fixtures/kubernetes-test-base.ts +++ b/apps/web/e2e/fixtures/kubernetes-test-base.ts @@ -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" }); diff --git a/apps/web/e2e/fixtures/kubernetes-tools.ts b/apps/web/e2e/fixtures/kubernetes-tools.ts index 4d03179ce7..e35a8775be 100644 --- a/apps/web/e2e/fixtures/kubernetes-tools.ts +++ b/apps/web/e2e/fixtures/kubernetes-tools.ts @@ -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 { return new Promise((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", diff --git a/apps/web/e2e/fixtures/kubernetes-worker-images.ts b/apps/web/e2e/fixtures/kubernetes-worker-images.ts new file mode 100644 index 0000000000..0e6742deab --- /dev/null +++ b/apps/web/e2e/fixtures/kubernetes-worker-images.ts @@ -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 = { + 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; +}; + +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 { + 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(); + 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 { + 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. + } + } +} diff --git a/apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts b/apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts index def96d39ff..158b5f785b 100644 --- a/apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts +++ b/apps/web/e2e/tests/kubernetes/kubernetes-executor.spec.ts @@ -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, diff --git a/apps/web/e2e/tests/kubernetes/kubernetes-worker-presets.spec.ts b/apps/web/e2e/tests/kubernetes/kubernetes-worker-presets.spec.ts new file mode 100644 index 0000000000..3729bfc7c5 --- /dev/null +++ b/apps/web/e2e/tests/kubernetes/kubernetes-worker-presets.spec.ts @@ -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); + } + }); + } +}); diff --git a/apps/web/e2e/tests/settings/kubernetes-executor.spec.ts b/apps/web/e2e/tests/settings/kubernetes-executor.spec.ts index fa00963482..b481b372e1 100644 --- a/apps/web/e2e/tests/settings/kubernetes-executor.spec.ts +++ b/apps/web/e2e/tests/settings/kubernetes-executor.spec.ts @@ -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(); + } +}); diff --git a/apps/web/e2e/tests/settings/mobile-kubernetes-executor.spec.ts b/apps/web/e2e/tests/settings/mobile-kubernetes-executor.spec.ts index 69d8c192d7..033db3e2b1 100644 --- a/apps/web/e2e/tests/settings/mobile-kubernetes-executor.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-kubernetes-executor.spec.ts @@ -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(); } diff --git a/apps/web/e2e/tests/system/backend-fixture-lifecycle.spec.ts b/apps/web/e2e/tests/system/backend-fixture-lifecycle.spec.ts index 4b249c1bc7..9f6cfa8980 100644 --- a/apps/web/e2e/tests/system/backend-fixture-lifecycle.spec.ts +++ b/apps/web/e2e/tests/system/backend-fixture-lifecycle.spec.ts @@ -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((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 () => { diff --git a/apps/web/hooks/domains/settings/use-kubernetes-settings.test.tsx b/apps/web/hooks/domains/settings/use-kubernetes-settings.test.tsx index a09b6c8349..96ffbbd697 100644 --- a/apps/web/hooks/domains/settings/use-kubernetes-settings.test.tsx +++ b/apps/web/hooks/domains/settings/use-kubernetes-settings.test.tsx @@ -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)); diff --git a/apps/web/hooks/domains/settings/use-kubernetes-settings.ts b/apps/web/hooks/domains/settings/use-kubernetes-settings.ts index 8f5014db76..218d721f67 100644 --- a/apps/web/hooks/domains/settings/use-kubernetes-settings.ts +++ b/apps/web/hooks/domains/settings/use-kubernetes-settings.ts @@ -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); diff --git a/apps/web/lib/api/domains/kubernetes-api.test.ts b/apps/web/lib/api/domains/kubernetes-api.test.ts index f986bcbc78..cbc6695ff6 100644 --- a/apps/web/lib/api/domains/kubernetes-api.test.ts +++ b/apps/web/lib/api/domains/kubernetes-api.test.ts @@ -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 }]), diff --git a/apps/web/lib/api/domains/kubernetes-api.ts b/apps/web/lib/api/domains/kubernetes-api.ts index 74957a594e..e1321d065f 100644 --- a/apps/web/lib/api/domains/kubernetes-api.ts +++ b/apps/web/lib/api/domains/kubernetes-api.ts @@ -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; + 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 | null { - return value !== null && typeof value === "object" ? (value as Record) : null; + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; } function finiteNumber(value: unknown): number { diff --git a/apps/web/lib/types/http-kubernetes.ts b/apps/web/lib/types/http-kubernetes.ts index 034e3e196f..38b053d7f8 100644 --- a/apps/web/lib/types/http-kubernetes.ts +++ b/apps/web/lib/types/http-kubernetes.ts @@ -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 = { diff --git a/apps/web/src/locales/en/executors.json b/apps/web/src/locales/en/executors.json index c12d5ab501..5f0b6ae77d 100644 --- a/apps/web/src/locales/en/executors.json +++ b/apps/web/src/locales/en/executors.json @@ -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", diff --git a/apps/web/src/locales/pseudo/executors.json b/apps/web/src/locales/pseudo/executors.json index bcf4e5d48d..d021ffe94c 100644 --- a/apps/web/src/locales/pseudo/executors.json +++ b/apps/web/src/locales/pseudo/executors.json @@ -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ĩśţĩńĝ ćĺàĩḿ", diff --git a/apps/web/src/locales/pt-pt/executors.json b/apps/web/src/locales/pt-pt/executors.json index 23df231ae3..496e96a3f5 100644 --- a/apps/web/src/locales/pt-pt/executors.json +++ b/apps/web/src/locales/pt-pt/executors.json @@ -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", diff --git a/apps/web/src/locales/zh-cn/executors.json b/apps/web/src/locales/zh-cn/executors.json index b50dd64215..ba649a88db 100644 --- a/apps/web/src/locales/zh-cn/executors.json +++ b/apps/web/src/locales/zh-cn/executors.json @@ -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": "现有存储声明", diff --git a/apps/web/src/locales/zh-hk/executors.json b/apps/web/src/locales/zh-hk/executors.json index 8a399a8bb7..3321a84e74 100644 --- a/apps/web/src/locales/zh-hk/executors.json +++ b/apps/web/src/locales/zh-hk/executors.json @@ -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": "現有存儲聲明", diff --git a/apps/web/src/locales/zh-tw/executors.json b/apps/web/src/locales/zh-tw/executors.json index 8fd5382fb4..8bf05d9bf9 100644 --- a/apps/web/src/locales/zh-tw/executors.json +++ b/apps/web/src/locales/zh-tw/executors.json @@ -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": "現有儲存宣告", diff --git a/docs/plans/kubernetes-retained-compute/plan.md b/docs/plans/kubernetes-retained-compute/plan.md new file mode 100644 index 0000000000..d2e1d5b091 --- /dev/null +++ b/docs/plans/kubernetes-retained-compute/plan.md @@ -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. diff --git a/docs/plans/kubernetes-retained-compute/task-01-retention-projection.md b/docs/plans/kubernetes-retained-compute/task-01-retention-projection.md new file mode 100644 index 0000000000..b5b5d04ee1 --- /dev/null +++ b/docs/plans/kubernetes-retained-compute/task-01-retention-projection.md @@ -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. diff --git a/docs/plans/kubernetes-retained-compute/task-02-retention-status.md b/docs/plans/kubernetes-retained-compute/task-02-retention-status.md new file mode 100644 index 0000000000..8e716d3fdf --- /dev/null +++ b/docs/plans/kubernetes-retained-compute/task-02-retention-status.md @@ -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. diff --git a/docs/plans/kubernetes-startup-timing/plan.md b/docs/plans/kubernetes-startup-timing/plan.md new file mode 100644 index 0000000000..0a486e1898 --- /dev/null +++ b/docs/plans/kubernetes-startup-timing/plan.md @@ -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. diff --git a/docs/plans/kubernetes-startup-timing/task-01-launch-durations.md b/docs/plans/kubernetes-startup-timing/task-01-launch-durations.md new file mode 100644 index 0000000000..fee84e65be --- /dev/null +++ b/docs/plans/kubernetes-startup-timing/task-01-launch-durations.md @@ -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` diff --git a/docs/plans/kubernetes-worker-presets/plan.md b/docs/plans/kubernetes-worker-presets/plan.md new file mode 100644 index 0000000000..4b8c75d893 --- /dev/null +++ b/docs/plans/kubernetes-worker-presets/plan.md @@ -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. diff --git a/docs/plans/kubernetes-worker-presets/task-01-worker-recipes.md b/docs/plans/kubernetes-worker-presets/task-01-worker-recipes.md new file mode 100644 index 0000000000..faa9a8c287 --- /dev/null +++ b/docs/plans/kubernetes-worker-presets/task-01-worker-recipes.md @@ -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` diff --git a/docs/plans/kubernetes-worker-presets/task-02-kind-presets.md b/docs/plans/kubernetes-worker-presets/task-02-kind-presets.md new file mode 100644 index 0000000000..6a3c93eedc --- /dev/null +++ b/docs/plans/kubernetes-worker-presets/task-02-kind-presets.md @@ -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` diff --git a/docs/public/executors.md b/docs/public/executors.md index b5787be4c2..13c4e0c4d1 100644 --- a/docs/public/executors.md +++ b/docs/public/executors.md @@ -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 diff --git a/docs/public/k8s.md b/docs/public/k8s.md index c14ed442b0..fe7a7c9104 100644 --- a/docs/public/k8s.md +++ b/docs/public/k8s.md @@ -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: diff --git a/docs/specs/executors/README.md b/docs/specs/executors/README.md index e458331840..51e688431b 100644 --- a/docs/specs/executors/README.md +++ b/docs/specs/executors/README.md @@ -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. diff --git a/docs/specs/executors/requirements/kubernetes-retained-compute.md b/docs/specs/executors/requirements/kubernetes-retained-compute.md new file mode 100644 index 0000000000..580a2ad547 --- /dev/null +++ b/docs/specs/executors/requirements/kubernetes-retained-compute.md @@ -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. diff --git a/docs/specs/executors/requirements/kubernetes-startup-timing.md b/docs/specs/executors/requirements/kubernetes-startup-timing.md new file mode 100644 index 0000000000..50711eb40b --- /dev/null +++ b/docs/specs/executors/requirements/kubernetes-startup-timing.md @@ -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. diff --git a/docs/specs/executors/requirements/kubernetes-worker-presets.md b/docs/specs/executors/requirements/kubernetes-worker-presets.md new file mode 100644 index 0000000000..36e554d1b5 --- /dev/null +++ b/docs/specs/executors/requirements/kubernetes-worker-presets.md @@ -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. diff --git a/docs/specs/executors/system-design/kubernetes-retained-compute.md b/docs/specs/executors/system-design/kubernetes-retained-compute.md new file mode 100644 index 0000000000..56616726a0 --- /dev/null +++ b/docs/specs/executors/system-design/kubernetes-retained-compute.md @@ -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/`. 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) diff --git a/docs/specs/executors/system-design/kubernetes-startup-timing.md b/docs/specs/executors/system-design/kubernetes-startup-timing.md new file mode 100644 index 0000000000..26df74178e --- /dev/null +++ b/docs/specs/executors/system-design/kubernetes-startup-timing.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). diff --git a/docs/specs/executors/system-design/kubernetes-worker-presets.md b/docs/specs/executors/system-design/kubernetes-worker-presets.md new file mode 100644 index 0000000000..d50ae24a7a --- /dev/null +++ b/docs/specs/executors/system-design/kubernetes-worker-presets.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). diff --git a/k8s/presets/minimal.yaml b/k8s/presets/minimal.yaml new file mode 100644 index 0000000000..391f470bda --- /dev/null +++ b/k8s/presets/minimal.yaml @@ -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 diff --git a/k8s/presets/node-pnpm.yaml b/k8s/presets/node-pnpm.yaml new file mode 100644 index 0000000000..795ed1e8bb --- /dev/null +++ b/k8s/presets/node-pnpm.yaml @@ -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 diff --git a/k8s/presets/python.yaml b/k8s/presets/python.yaml new file mode 100644 index 0000000000..7e6becc15d --- /dev/null +++ b/k8s/presets/python.yaml @@ -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 diff --git a/k8s/worker-images/Dockerfile b/k8s/worker-images/Dockerfile new file mode 100644 index 0000000000..a3274667f8 --- /dev/null +++ b/k8s/worker-images/Dockerfile @@ -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 diff --git a/k8s/worker-images/README.md b/k8s/worker-images/README.md new file mode 100644 index 0000000000..b50343de66 --- /dev/null +++ b/k8s/worker-images/README.md @@ -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. diff --git a/k8s/worker-images/pins.env b/k8s/worker-images/pins.env new file mode 100644 index 0000000000..34a4747d4d --- /dev/null +++ b/k8s/worker-images/pins.env @@ -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 diff --git a/scripts/test-kubernetes-worker-images.sh b/scripts/test-kubernetes-worker-images.sh new file mode 100755 index 0000000000..9b0140753b --- /dev/null +++ b/scripts/test-kubernetes-worker-images.sh @@ -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