Skip to content

Commit ab24c68

Browse files
authored
refactor(cli): tighten session flow and review cleanup (#20)
* feat(init): add stignore presets and autodetect * feat(sync): make syncthing rescan interval configurable * fix(sync): isolate local syncthing logs and cleanup * test(sync): expand syncthing coverage * test: expand coverage for kube and syncthing helpers * feat(sync): make syncthing relays configurable * refactor(cli): extract ssh reconnect loop * refactor(cli): split up flow and centralize command context * refactor(cli): extend command context rollout * fix: address remaining review findings * refactor: tighten remaining review cleanup * refactor(cli): finish remaining cleanup * chore: stop tracking local review notes * fix: keep review notes untracked without ignore rule * test: expand coverage across helper modules * test: raise kube and ssh coverage * style(test): format kube coverage tests * style: format remaining go files
1 parent 854b8e3 commit ab24c68

46 files changed

Lines changed: 3413 additions & 686 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ See:
3131
- `docs/troubleshooting.md`
3232
- `docs/release.md`
3333

34+
## Development
35+
36+
Coverage on some Go 1.25 toolchains can fail under `go test ./... -cover` because the bundled `covdata` tool is missing for packages without tests. Use the repo helper instead:
37+
38+
```bash
39+
./scripts/coverage.sh
40+
```
41+
3442
## GitHub Pages Docs
3543

3644
Docs are published from `docs/` via GitHub Actions + MkDocs.

cmd/okdev-sshd/main_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package main
22

33
import (
4+
"errors"
5+
"os"
6+
"os/exec"
7+
"strings"
48
"testing"
59

610
"github.com/gliderlabs/ssh"
@@ -37,3 +41,118 @@ func TestNewServerAddsPublicKeyHandlerWhenKeysProvided(t *testing.T) {
3741
t.Fatal("expected public key handler to be configured")
3842
}
3943
}
44+
45+
func TestDetectShellReturnsExistingShell(t *testing.T) {
46+
got := detectShell()
47+
if got != "/bin/bash" && got != "/bin/sh" {
48+
t.Fatalf("unexpected shell %q", got)
49+
}
50+
if _, err := os.Stat(got); err != nil {
51+
t.Fatalf("expected detected shell to exist: %v", err)
52+
}
53+
}
54+
55+
func TestLoadAuthorizedKeysMissingFileReturnsNil(t *testing.T) {
56+
keys, err := loadAuthorizedKeys("/definitely/missing/authorized_keys")
57+
if err != nil {
58+
t.Fatalf("unexpected error: %v", err)
59+
}
60+
if keys != nil {
61+
t.Fatalf("expected nil keys for missing file, got %d", len(keys))
62+
}
63+
}
64+
65+
func TestLoadAuthorizedKeysParsesMultipleKeys(t *testing.T) {
66+
path := t.TempDir() + "/authorized_keys"
67+
content := strings.Join([]string{
68+
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE9mN6e2Q2x8tQz4pT2r8j04YfGLwRoTSesFiNUFDXL9 test1",
69+
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ2uL4N6OQ9bQG6tW1c2GqU2o6L1Qm0f0g5d2sWlY4Hn test2",
70+
"",
71+
}, "\n")
72+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
73+
t.Fatal(err)
74+
}
75+
76+
keys, err := loadAuthorizedKeys(path)
77+
if err != nil {
78+
t.Fatalf("unexpected error: %v", err)
79+
}
80+
if len(keys) != 2 {
81+
t.Fatalf("unexpected key count: got %d want 2", len(keys))
82+
}
83+
}
84+
85+
func TestLoadAuthorizedKeysRejectsInvalidData(t *testing.T) {
86+
path := t.TempDir() + "/authorized_keys"
87+
if err := os.WriteFile(path, []byte("not a key\n"), 0o644); err != nil {
88+
t.Fatal(err)
89+
}
90+
91+
if _, err := loadAuthorizedKeys(path); err == nil {
92+
t.Fatal("expected parse error")
93+
}
94+
}
95+
96+
func TestBuildInteractiveLoginScript(t *testing.T) {
97+
script := buildInteractiveLoginScript(
98+
map[string]string{},
99+
"/bin/sh",
100+
"/workspace/demo",
101+
"1",
102+
)
103+
104+
for _, want := range []string{
105+
"cd '/workspace/demo'",
106+
"/workspace/demo/.okdev/post-attach.sh",
107+
"tmux",
108+
"exec '/bin/sh' -l",
109+
} {
110+
if !strings.Contains(script, want) {
111+
t.Fatalf("expected script to contain %q: %s", want, script)
112+
}
113+
}
114+
}
115+
116+
func TestBuildInteractiveLoginScriptSkipsTmuxWhenDisabled(t *testing.T) {
117+
script := buildInteractiveLoginScript(
118+
map[string]string{"OKDEV_NO_TMUX": "1"},
119+
"/bin/bash",
120+
"",
121+
"1",
122+
)
123+
124+
if strings.Contains(script, "tmux") {
125+
t.Fatalf("expected tmux bootstrap to be skipped: %s", script)
126+
}
127+
if !strings.Contains(script, "exec '/bin/bash' -l") {
128+
t.Fatalf("expected login shell exec: %s", script)
129+
}
130+
}
131+
132+
func TestShellQuoteEscapesSingleQuotes(t *testing.T) {
133+
if got := shellQuote("a'b"); got != `'a'"'"'b'` {
134+
t.Fatalf("unexpected quoted string: %s", got)
135+
}
136+
if got := shellQuote(""); got != "''" {
137+
t.Fatalf("unexpected empty quoted string: %s", got)
138+
}
139+
}
140+
141+
func TestExitStatus(t *testing.T) {
142+
if got := exitStatus(nil); got != 0 {
143+
t.Fatalf("unexpected nil exit status: %d", got)
144+
}
145+
146+
if got := exitStatus(errors.New("plain")); got != 1 {
147+
t.Fatalf("unexpected generic exit status: %d", got)
148+
}
149+
150+
cmd := exec.Command("/bin/sh", "-c", "exit 7")
151+
err := cmd.Run()
152+
if err == nil {
153+
t.Fatal("expected exit error")
154+
}
155+
if got := exitStatus(err); got != 7 {
156+
t.Fatalf("unexpected exit status: got %d want 7", got)
157+
}
158+
}

docs/config-manifest.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ spec:
165165
| `syncthing.version` | `string` | `v1.29.7` | Local Syncthing binary version |
166166
| `syncthing.autoInstall` | `bool` | `true` | Auto-install local Syncthing |
167167
| `syncthing.image` | `string` | `ghcr.io/acmore/okdev:<version>` | Sidecar image (fallback: `edge`) |
168+
| `syncthing.rescanIntervalSeconds` | `int` | `300` | Fallback full rescan interval; `0` disables periodic rescans |
169+
| `syncthing.relaysEnabled` | `bool` | `false` | Allow Syncthing relay fallback when direct connectivity is unavailable |
168170

169171
**Validation:** `engine` must be `syncthing`; each `paths[]` entry must be `local:remote`.
170172

@@ -177,6 +179,8 @@ spec:
177179
syncthing:
178180
version: v1.29.7
179181
autoInstall: true
182+
rescanIntervalSeconds: 300
183+
relaysEnabled: false
180184
paths:
181185
- .:/workspace
182186
remoteExclude:

docs/quickstart.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ okdev sync --mode down
126126
```
127127

128128
Local Syncthing is auto-installed when `sync.engine=syncthing`.
129+
If your local filesystem watcher occasionally misses changes, set `spec.sync.syncthing.rescanIntervalSeconds` to a lower value such as `300` for a faster fallback rescan.
129130

130131
## Port Forwarding
131132

internal/cli/common.go

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,19 @@ import (
2525
apierrors "k8s.io/apimachinery/pkg/api/errors"
2626
)
2727

28-
const sessionHeartbeatInterval = 5 * time.Minute
29-
const sshPort = 2222
30-
3128
var invalidOwnerChars = regexp.MustCompile(`[^a-z0-9._-]`)
3229

30+
type sessionResolver func(*Options, *config.DevEnvironment, string) (string, error)
31+
32+
type commandContext struct {
33+
opts *Options
34+
cfg *config.DevEnvironment
35+
cfgPath string
36+
namespace string
37+
sessionName string
38+
kube *kube.Client
39+
}
40+
3341
func loadConfigAndNamespace(opts *Options) (*config.DevEnvironment, string, error) {
3442
path, err := config.ResolvePath(opts.ConfigPath)
3543
if err != nil {
@@ -56,6 +64,33 @@ func loadConfigAndNamespace(opts *Options) (*config.DevEnvironment, string, erro
5664
return cfg, ns, nil
5765
}
5866

67+
func resolveCommandContext(opts *Options, resolver sessionResolver) (*commandContext, error) {
68+
cfg, namespace, err := loadConfigAndNamespace(opts)
69+
if err != nil {
70+
return nil, err
71+
}
72+
cfgPath, err := config.ResolvePath(opts.ConfigPath)
73+
if err != nil {
74+
return nil, err
75+
}
76+
cc := &commandContext{
77+
opts: opts,
78+
cfg: cfg,
79+
cfgPath: cfgPath,
80+
namespace: namespace,
81+
kube: newKubeClient(opts),
82+
}
83+
if resolver == nil {
84+
return cc, nil
85+
}
86+
sessionName, err := resolver(opts, cfg, namespace)
87+
if err != nil {
88+
return nil, err
89+
}
90+
cc.sessionName = sessionName
91+
return cc, nil
92+
}
93+
5994
func withQuietConfigAnnounce(fn func() error) error {
6095
prevQuiet, hadQuiet := os.LookupEnv("OKDEV_QUIET_CONFIG_ANNOUNCE")
6196
_ = os.Setenv("OKDEV_QUIET_CONFIG_ANNOUNCE", "1")
@@ -139,7 +174,7 @@ func newTransientStatusWithMode(w io.Writer, message string, interactive bool) *
139174
s.enabled = true
140175
s.stopCh = make(chan struct{})
141176
s.doneCh = make(chan struct{})
142-
go s.run(120 * time.Millisecond)
177+
go s.run(statusSpinnerInterval)
143178
return s
144179
}
145180

@@ -225,10 +260,6 @@ func resolveSessionName(opts *Options, cfg *config.DevEnvironment, namespace str
225260
return resolveSessionNameWithState(opts, cfg, namespace, true)
226261
}
227262

228-
func resolveSessionNameForUpDown(opts *Options, cfg *config.DevEnvironment, namespace string) (string, error) {
229-
return resolveSessionNameWithState(opts, cfg, namespace, true)
230-
}
231-
232263
func resolveSessionNameWithState(opts *Options, cfg *config.DevEnvironment, namespace string, inferExisting bool) (string, error) {
233264
return resolveSessionNameWithReader(opts, cfg, namespace, inferExisting, newKubeClient(opts))
234265
}
@@ -251,7 +282,9 @@ func resolveSessionNameWithReader(opts *Options, cfg *config.DevEnvironment, nam
251282
if exists {
252283
return resolvedActive, nil
253284
}
254-
_ = session.ClearActiveSession()
285+
if clearErr := session.ClearActiveSession(); clearErr != nil {
286+
slog.Debug("failed to clear stale active session", "session", resolvedActive, "error", clearErr)
287+
}
255288
} else {
256289
slog.Debug("failed to verify active session pod", "session", resolvedActive, "namespace", namespace, "error", existsErr)
257290
return resolvedActive, nil
@@ -270,7 +303,7 @@ func resolveSessionNameWithReader(opts *Options, cfg *config.DevEnvironment, nam
270303
}
271304

272305
func sessionPodExists(k sessionAccessReader, namespace, sessionName string) (bool, error) {
273-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
306+
ctx, cancel := context.WithTimeout(context.Background(), sessionExistsTimeout)
274307
defer cancel()
275308
pods, err := k.ListPods(ctx, namespace, false, "okdev.io/managed=true,okdev.io/session="+sessionName)
276309
if err == nil {
@@ -303,7 +336,7 @@ func inferExistingSessionForRepo(opts *Options, cfg *config.DevEnvironment, name
303336
if strings.TrimSpace(cfg.Metadata.Name) != "" {
304337
label = append(label, "okdev.io/name="+cfg.Metadata.Name)
305338
}
306-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
339+
ctx, cancel := context.WithTimeout(context.Background(), sessionExistsTimeout)
307340
defer cancel()
308341
pods, err := newKubeClient(opts).ListPods(ctx, namespace, false, strings.Join(label, ","))
309342
if err != nil {
@@ -376,7 +409,7 @@ func newKubeClient(opts *Options) *kube.Client {
376409
}
377410

378411
func defaultContext() (context.Context, context.CancelFunc) {
379-
return context.WithTimeout(context.Background(), 5*time.Minute)
412+
return context.WithTimeout(context.Background(), defaultContextTimeout)
380413
}
381414

382415
func interactiveContext() (context.Context, context.CancelFunc) {
@@ -394,13 +427,11 @@ func runConnectWithClient(k *kube.Client, namespace string, target workload.Targ
394427
return connect.Run(ctx, k, namespace, target.PodName, command, tty, os.Stdin, os.Stdout, os.Stderr)
395428
}
396429

397-
func startSessionMaintenance(opts *Options, cfg *config.DevEnvironment, namespace, sessionName string, out io.Writer, renewLock bool, emitHeartbeat bool) func() {
398-
return startSessionMaintenanceWithClient(newKubeClient(opts), cfg, namespace, sessionName, out, renewLock, emitHeartbeat)
430+
func startSessionMaintenance(opts *Options, namespace, sessionName string, out io.Writer, emitHeartbeat bool) func() {
431+
return startSessionMaintenanceWithClient(newKubeClient(opts), namespace, sessionName, out, emitHeartbeat)
399432
}
400433

401-
func startSessionMaintenanceWithClient(k *kube.Client, cfg *config.DevEnvironment, namespace, sessionName string, out io.Writer, renewLock bool, emitHeartbeat bool) func() {
402-
_ = cfg
403-
_ = renewLock
434+
func startSessionMaintenanceWithClient(k *kube.Client, namespace, sessionName string, out io.Writer, emitHeartbeat bool) func() {
404435
if !emitHeartbeat {
405436
return func() {}
406437
}
@@ -410,13 +441,15 @@ func startSessionMaintenanceWithClient(k *kube.Client, cfg *config.DevEnvironmen
410441
pod = target.PodName
411442
}
412443
ctx, cancel := context.WithCancel(context.Background())
444+
done := make(chan struct{})
413445

414446
go func() {
447+
defer close(done)
415448
heartbeatTicker := time.NewTicker(sessionHeartbeatInterval)
416449
defer heartbeatTicker.Stop()
417450

418451
doHeartbeat := func() {
419-
beatCtx, beatCancel := context.WithTimeout(context.Background(), 10*time.Second)
452+
beatCtx, beatCancel := context.WithTimeout(context.Background(), heartbeatWriteTimeout)
420453
err := k.TouchPodActivity(beatCtx, namespace, pod)
421454
beatCancel()
422455
if err != nil {
@@ -436,7 +469,10 @@ func startSessionMaintenanceWithClient(k *kube.Client, cfg *config.DevEnvironmen
436469
}
437470
}()
438471

439-
return cancel
472+
return func() {
473+
cancel()
474+
<-done
475+
}
440476
}
441477

442478
func currentOwner(opts *Options) string {
@@ -487,7 +523,7 @@ type sessionAccessReader interface {
487523
}
488524

489525
func ensureSessionAccess(opts *Options, k sessionAccessReader, namespace, sessionName string, allowShareable bool, requireExisting bool) error {
490-
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
526+
ctx, cancel := context.WithTimeout(context.Background(), sessionAccessTimeout)
491527
defer cancel()
492528
pods, err := k.ListPods(ctx, namespace, false, "okdev.io/managed=true,okdev.io/session="+sessionName)
493529
if err != nil {

internal/cli/connect.go

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,18 @@ func newConnectCmd(opts *Options) *cobra.Command {
1717
Args: cobra.MaximumNArgs(1),
1818
RunE: func(cmd *cobra.Command, args []string) error {
1919
applySessionArg(opts, args)
20-
cfg, ns, err := loadConfigAndNamespace(opts)
20+
cc, err := resolveCommandContext(opts, resolveSessionName)
2121
if err != nil {
2222
return err
2323
}
24-
sn, err := resolveSessionName(opts, cfg, ns)
25-
if err != nil {
26-
return err
27-
}
28-
k := newKubeClient(opts)
29-
if err := ensureExistingSessionOwnership(opts, k, ns, sn, true); err != nil {
24+
if err := ensureExistingSessionOwnership(opts, cc.kube, cc.namespace, cc.sessionName, true); err != nil {
3025
return err
3126
}
32-
target, err := resolveTargetRef(cmd.Context(), opts, cfg, ns, sn, k)
27+
target, err := resolveTargetRef(cmd.Context(), opts, cc.cfg, cc.namespace, cc.sessionName, cc.kube)
3328
if err != nil {
3429
return err
3530
}
36-
stopMaintenance := startSessionMaintenance(opts, cfg, ns, sn, cmd.OutOrStdout(), true, true)
31+
stopMaintenance := startSessionMaintenance(opts, cc.namespace, cc.sessionName, cmd.OutOrStdout(), true)
3732
defer stopMaintenance()
3833

3934
execCmd := []string{"sh", "-lc", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
@@ -49,7 +44,7 @@ func newConnectCmd(opts *Options) *cobra.Command {
4944
if len(execCmd) == 1 && strings.TrimSpace(execCmd[0]) == "" {
5045
execCmd = []string{"sh", "-lc", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
5146
}
52-
return runConnectWithClient(k, ns, target, execCmd, !noTTY)
47+
return runConnectWithClient(cc.kube, cc.namespace, target, execCmd, !noTTY)
5348
},
5449
}
5550
cmd.Flags().StringVar(&shell, "shell", "", "Shell to start (default auto-detects bash/sh)")

0 commit comments

Comments
 (0)