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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
- `okdev template show <name>`
- `okdev validate`
- `okdev up [--wait-timeout 10m] [--dry-run]`
- `okdev down [session] [--delete-pvc] [--dry-run] [--output json]`
- `okdev down [session] [--delete-pvc] [--dry-run] [--wait] [--wait-timeout 2m] [--output json]`
- `okdev status [session] [--all] [--all-users] [--details]`
- `okdev list [--all-namespaces] [--all-users]`
- `okdev use <session>`
Expand Down Expand Up @@ -143,12 +143,14 @@
- When `sync.engine=syncthing`, `okdev up` refreshes the session's local Syncthing processes, starts background sync in bidirectional mode by default, and waits for the initial sync to converge before exiting.
- `spec.ports` is materialized as SSH `LocalForward` or `RemoteForward` based on `direction`.

### `okdev down [session] [--delete-pvc] [--dry-run] [--output json]`
### `okdev down [session] [--delete-pvc] [--dry-run] [--wait] [--wait-timeout 2m] [--output json]`

- Deletes the current session workload and cleans up local SSH/sync metadata.
- When `session` is provided, `okdev` can resolve the saved config from session metadata even outside the repo.
- Prompts for confirmation by default; use `--yes` in scripts or non-interactive environments.
- `--dry-run`: previews what would be deleted without removing cluster or local state.
- `--wait`: waits for the workload object to disappear and then for any remaining session pods to terminate before returning.
- `--wait-timeout`: caps the total time spent waiting for workload/pod termination when `--wait` is enabled.
- `--output json`: emits a machine-readable summary of the planned or completed deletion and local cleanup steps.
- `--delete-pvc` remains accepted for compatibility but is ignored; `okdev` no longer manages PVC lifecycle automatically.

Expand Down
3 changes: 3 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ okdev down --dry-run

```bash
okdev down

# Block until the workload object and session pods are fully gone
okdev down --wait
okdev prune --ttl-hours 72
```

Expand Down
206 changes: 194 additions & 12 deletions internal/cli/down.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,50 @@ package cli

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"time"

"github.com/acmore/okdev/internal/kube"
"github.com/acmore/okdev/internal/session"
"github.com/acmore/okdev/internal/workload"
"github.com/spf13/cobra"
)

var downWaitPollInterval = 200 * time.Millisecond

type downWaitOutput struct {
Enabled bool `json:"enabled"`
Timeout string `json:"timeout,omitempty"`
Status string `json:"status,omitempty"`
WorkloadDeleted bool `json:"workloadDeleted,omitempty"`
PodsDeleted bool `json:"podsDeleted,omitempty"`
}

type downOutput struct {
Session string `json:"session"`
Namespace string `json:"namespace"`
Kind string `json:"kind"`
Workload string `json:"workload"`
DryRun bool `json:"dryRun"`
Deleted bool `json:"deleted"`
Status string `json:"status"`
Notes []string `json:"notes,omitempty"`
Cleanup map[string]any `json:"cleanup,omitempty"`
Session string `json:"session"`
Namespace string `json:"namespace"`
Kind string `json:"kind"`
Workload string `json:"workload"`
DryRun bool `json:"dryRun"`
Deleted bool `json:"deleted"`
Status string `json:"status"`
Notes []string `json:"notes,omitempty"`
Wait *downWaitOutput `json:"wait,omitempty"`
Cleanup map[string]any `json:"cleanup,omitempty"`
}

func newDownCmd(opts *Options) *cobra.Command {
var deletePVC bool
var dryRun bool
var wait bool
var waitTimeout time.Duration
var yes bool

cmd := &cobra.Command{
Expand All @@ -46,6 +64,9 @@ func newDownCmd(opts *Options) *cobra.Command {
# Emit a machine-readable delete summary
okdev down --output json --yes

# Delete and wait for the workload and matching pods to disappear
okdev down --wait

# Delete a specific session
okdev down my-feature -y`,
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -73,7 +94,7 @@ func newDownCmd(opts *Options) *cobra.Command {
return err
}
ui.stepDone("ownership", "ok")
ctx, cancel := defaultContext()
ctx, cancel := downCommandContext(wait, waitTimeout)
defer cancel()
exists, err := shouldReuseExistingWorkload(ctx, cc.kube, cc.namespace, runtime)
if err != nil {
Expand All @@ -89,6 +110,13 @@ func newDownCmd(opts *Options) *cobra.Command {
Deleted: false,
Status: "planned",
}
if wait {
payload.Wait = &downWaitOutput{
Enabled: true,
Timeout: waitTimeout.String(),
Status: "planned",
}
}
if deletePVC {
payload.Notes = append(payload.Notes, "--delete-pvc ignored: okdev no longer manages PVC lifecycle")
}
Expand All @@ -109,6 +137,9 @@ func newDownCmd(opts *Options) *cobra.Command {
if deletePVC {
fmt.Fprintln(cmd.OutOrStdout(), "- note: --delete-pvc is ignored (okdev no longer manages PVC lifecycle)")
}
if wait {
fmt.Fprintf(cmd.OutOrStdout(), "- would wait for workload deletion and session pods to terminate (timeout=%s)\n", waitTimeout)
}
return nil
}

Expand All @@ -124,6 +155,15 @@ func newDownCmd(opts *Options) *cobra.Command {
Notes: []string{"session workload already absent"},
Cleanup: map[string]any{},
}
if wait {
payload.Wait = &downWaitOutput{
Enabled: true,
Timeout: waitTimeout.String(),
Status: "already stopped",
WorkloadDeleted: true,
PodsDeleted: true,
}
}
if err := downCleanupLocal(ui, &payload, cc.sessionName); err != nil {
return err
}
Expand Down Expand Up @@ -160,6 +200,14 @@ func newDownCmd(opts *Options) *cobra.Command {
Status: "stopped",
Cleanup: map[string]any{},
}
if wait {
payload.Status = "terminating"
payload.Wait = &downWaitOutput{
Enabled: true,
Timeout: waitTimeout.String(),
Status: "waiting",
}
}
if len(cc.cfg.Spec.Agents) > 0 {
if target, err := resolveTargetRef(ctx, cc.opts, cc.cfg, cc.namespace, cc.sessionName, cc.kube); err != nil {
ui.warnf("failed to resolve target before agent auth cleanup: %v", err)
Expand All @@ -179,6 +227,21 @@ func newDownCmd(opts *Options) *cobra.Command {
return fmt.Errorf("delete session %s: %w", runtime.Kind(), err)
}
ui.stepDone(runtime.Kind(), "deleted")
var waitErr error
if wait {
ui.section("Wait")
ui.stepRun("termination", runtime.WorkloadName())
waitResult, err := waitForDownDeletion(ctx, cc.kube, cc.namespace, cc.sessionName, runtime, waitTimeout)
payload.Wait = &waitResult
if err != nil {
waitErr = err
payload.Status = "terminating"
ui.warnf("%v", err)
} else {
payload.Status = "stopped"
ui.stepDone("termination", "workload deleted and session pods gone")
}
}
if deletePVC {
ui.warnf("--delete-pvc ignored: okdev no longer manages PVC lifecycle; delete PVCs manually if needed")
payload.Notes = append(payload.Notes, "--delete-pvc ignored: okdev no longer manages PVC lifecycle; delete PVCs manually if needed")
Expand All @@ -189,24 +252,143 @@ func newDownCmd(opts *Options) *cobra.Command {
return err
}
if cc.opts.Output == "json" {
return outputJSON(cmd.OutOrStdout(), payload)
if err := outputJSON(cmd.OutOrStdout(), payload); err != nil {
return err
}
return waitErr
}
if waitErr != nil {
ui.printWarnings()
return waitErr
}
ui.printWarnings()
ui.section("Ready")
fmt.Fprintf(cmd.OutOrStdout(), "session: %s\n", cc.sessionName)
fmt.Fprintf(cmd.OutOrStdout(), "namespace: %s\n", cc.namespace)
fmt.Fprintln(cmd.OutOrStdout(), "status: stopped")
fmt.Fprintln(cmd.OutOrStdout(), "workspace: pod deleted; volumes/PVCs unchanged")
if wait {
fmt.Fprintln(cmd.OutOrStdout(), "workspace: workload deleted and session pods terminated; volumes/PVCs unchanged")
} else {
fmt.Fprintln(cmd.OutOrStdout(), "workspace: pod deleted; volumes/PVCs unchanged")
}
return nil
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt")
cmd.Flags().BoolVar(&deletePVC, "delete-pvc", false, "Delete workspace PVC for this session")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview actions without deleting resources")
cmd.Flags().BoolVar(&wait, "wait", false, "Wait for workload deletion and session pod termination")
cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", downDefaultWaitTimeout, "Wait timeout for workload termination")
_ = cmd.Flags().MarkDeprecated("delete-pvc", "PVC lifecycle is no longer managed; delete PVCs manually if needed")
return cmd
}

type downWaitClient interface {
ResourceExists(context.Context, string, string, string, string) (bool, error)
ListPods(context.Context, string, bool, string) ([]kube.PodSummary, error)
}

func downCommandContext(wait bool, waitTimeout time.Duration) (context.Context, context.CancelFunc) {
timeout := defaultContextTimeout
if wait {
needed := waitTimeout + downContextBuffer
if needed > timeout {
timeout = needed
}
}
return context.WithTimeout(context.Background(), timeout)
}

func waitForDownDeletion(ctx context.Context, k downWaitClient, namespace, sessionName string, runtime workload.Runtime, timeout time.Duration) (downWaitOutput, error) {
result := downWaitOutput{
Enabled: true,
Timeout: timeout.String(),
Status: "waiting",
}
ref, ok := runtime.(workload.RefProvider)
if !ok {
return result, fmt.Errorf("wait for %s deletion is unsupported", runtime.Kind())
}
apiVersion, kind, name, err := ref.WorkloadRef()
if err != nil {
return result, fmt.Errorf("resolve workload reference for wait: %w", err)
}
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := waitForResourceDeletion(waitCtx, k, namespace, apiVersion, kind, name); err != nil {
result.Status = "timed out"
return result, err
}
result.WorkloadDeleted = true
if err := waitForSessionPodsDeleted(waitCtx, k, namespace, sessionName); err != nil {
result.Status = "timed out"
return result, err
}
result.PodsDeleted = true
result.Status = "completed"
return result, nil
}

func waitForResourceDeletion(ctx context.Context, k downWaitClient, namespace, apiVersion, kind, name string) error {
timeoutMessage := fmt.Sprintf("timed out waiting for %s/%s to be deleted", kind, name)
ticker := time.NewTicker(downWaitPollInterval)
defer ticker.Stop()
for {
exists, err := k.ResourceExists(ctx, namespace, apiVersion, kind, name)
if err != nil {
if ctx.Err() != nil {
return downWaitContextError(ctx, timeoutMessage)
}
return fmt.Errorf("check %s/%s deletion: %w", kind, name, err)
}
if !exists {
return nil
}
select {
case <-ctx.Done():
return downWaitContextError(ctx, timeoutMessage)
case <-ticker.C:
}
}
}

func waitForSessionPodsDeleted(ctx context.Context, k downWaitClient, namespace, sessionName string) error {
ticker := time.NewTicker(downWaitPollInterval)
defer ticker.Stop()
selector := "okdev.io/managed=true,okdev.io/session=" + sessionName
for {
pods, err := k.ListPods(ctx, namespace, false, selector)
if err != nil {
if ctx.Err() != nil {
return downWaitContextError(ctx, "timed out waiting for session pods to terminate")
}
return fmt.Errorf("list session pods while waiting for deletion: %w", err)
}
if len(pods) == 0 {
return nil
}
select {
case <-ctx.Done():
names := make([]string, 0, len(pods))
for _, pod := range pods {
names = append(names, pod.Name)
}
return downWaitContextError(ctx, fmt.Sprintf("timed out waiting for session pods to terminate: %s", strings.Join(names, ", ")))
case <-ticker.C:
}
}
}

func downWaitContextError(ctx context.Context, timeoutMessage string) error {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return errors.New(timeoutMessage)
}
if err := ctx.Err(); err != nil {
return err
}
return errors.New(timeoutMessage)
}

func confirmDown(in io.Reader, out io.Writer, sessionName, namespace, kind, workloadName string) (bool, error) {
if !isTerminalReader(in) {
return false, fmt.Errorf("refusing to delete without --yes in non-interactive mode")
Expand Down
Loading
Loading