Skip to content

Commit 8f80b34

Browse files
author
gitlayzer
committed
fix: serialize tunnel lifecycle operations
1 parent aea034b commit 8f80b34

7 files changed

Lines changed: 367 additions & 102 deletions

File tree

cmd/cleanup.go

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,17 @@ var cleanupCmd = &cobra.Command{
2121
return fmt.Errorf("--all cannot be used with a specific tunnel id")
2222
}
2323
if len(args) > 0 {
24-
sess, err := findSession(args[0])
24+
eligible, deleteFailed, err := cleanupTunnelWithLock(cmd, args[0], cleanupAll)
2525
if err != nil {
26-
return err
27-
}
28-
if !cleanupAll && !sessionCleanupEligible(*sess, time.Minute) {
29-
return fmt.Errorf("tunnel %s is not stopped, expired, stale, or error; refusing cleanup without --all", sess.TunnelID)
30-
}
31-
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
32-
defer cancel()
33-
if err := cleanupSessionResources(ctx, *sess); err != nil {
34-
return fmt.Errorf("cleanup tunnel %s: %w", sess.TunnelID, err)
26+
if deleteFailed {
27+
return err
28+
}
29+
return fmt.Errorf("cleanup tunnel %s: %w", args[0], err)
3530
}
36-
if err := session.Delete(sess.TunnelID); err != nil {
37-
return fmt.Errorf("delete local session %s: %w", sess.TunnelID, err)
31+
if !eligible {
32+
return fmt.Errorf("tunnel %s is not stopped, expired, stale, or error; refusing cleanup without --all", args[0])
3833
}
39-
fmt.Printf("Cleanup complete. Removed tunnel %s and its remote resources.\n", sess.TunnelID)
34+
fmt.Printf("Cleanup complete. Removed tunnel %s and its remote resources.\n", args[0])
4035
return nil
4136
}
4237
sessions, err := session.List()
@@ -48,17 +43,15 @@ var cleanupCmd = &cobra.Command{
4843
removed := 0
4944
failed := 0
5045
for _, sess := range sessions {
51-
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
52-
if err := cleanupSessionResources(ctx, sess); err != nil {
53-
cancel()
46+
_, deleteFailed, err := cleanupTunnelWithLock(cmd, sess.TunnelID, true)
47+
if err != nil {
48+
if deleteFailed {
49+
return err
50+
}
5451
failed++
5552
fmt.Fprintf(cmd.ErrOrStderr(), "[!] Skipped tunnel %s: %v\n", sess.TunnelID, err)
5653
continue
5754
}
58-
cancel()
59-
if err := session.Delete(sess.TunnelID); err != nil {
60-
return fmt.Errorf("delete local session %s: %w", sess.TunnelID, err)
61-
}
6255
removed++
6356
}
6457

@@ -73,14 +66,11 @@ var cleanupCmd = &cobra.Command{
7366
skipped := 0
7467
failed := 0
7568
for _, sess := range sessions {
76-
if !sessionCleanupEligible(sess, time.Minute) {
77-
skipped++
78-
continue
79-
}
80-
81-
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
82-
if err := cleanupSessionResources(ctx, sess); err != nil {
83-
cancel()
69+
eligible, deleteFailed, err := cleanupTunnelWithLock(cmd, sess.TunnelID, false)
70+
if err != nil {
71+
if deleteFailed {
72+
return err
73+
}
8474
failed++
8575
if errors.Is(err, errMissingSessionKubeconfig) {
8676
fmt.Fprintf(cmd.ErrOrStderr(), "[!] Skipped cleanup-eligible tunnel %s: %v\n", sess.TunnelID, err)
@@ -89,9 +79,9 @@ var cleanupCmd = &cobra.Command{
8979
fmt.Fprintf(cmd.ErrOrStderr(), "[!] Failed to clean up cleanup-eligible tunnel %s: %v\n", sess.TunnelID, err)
9080
continue
9181
}
92-
cancel()
93-
if err := session.Delete(sess.TunnelID); err != nil {
94-
return fmt.Errorf("delete local session %s: %w", sess.TunnelID, err)
82+
if !eligible {
83+
skipped++
84+
continue
9585
}
9686
cleaned++
9787
}
@@ -108,3 +98,29 @@ func init() {
10898
rootCmd.AddCommand(cleanupCmd)
10999
cleanupCmd.Flags().BoolVar(&cleanupAll, "all", false, "Force delete all locally tracked Sealtun tunnel resources and remove matching local session records")
110100
}
101+
102+
func cleanupTunnelWithLock(cmd *cobra.Command, tunnelID string, force bool) (bool, bool, error) {
103+
eligible := false
104+
deleteFailed := false
105+
err := withTunnelOperationLock(tunnelID, func() error {
106+
sess, err := findSession(tunnelID)
107+
if err != nil {
108+
return err
109+
}
110+
if !force && !sessionCleanupEligible(*sess, time.Minute) {
111+
return nil
112+
}
113+
eligible = true
114+
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
115+
defer cancel()
116+
if err := cleanupSessionResources(ctx, *sess); err != nil {
117+
return err
118+
}
119+
if err := session.Delete(sess.TunnelID); err != nil {
120+
deleteFailed = true
121+
return fmt.Errorf("delete local session %s: %w", sess.TunnelID, err)
122+
}
123+
return nil
124+
})
125+
return eligible, deleteFailed, err
126+
}

cmd/cleanup_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cmd
22

33
import (
44
"context"
5+
"errors"
6+
"fmt"
57
"strings"
68
"testing"
79
"time"
@@ -77,3 +79,37 @@ func TestCleanupSpecificTunnelRejectsActiveSession(t *testing.T) {
7779
t.Fatalf("expected active cleanup refusal, got %v", err)
7880
}
7981
}
82+
83+
func TestCleanupWaitsForTunnelOperationLock(t *testing.T) {
84+
t.Setenv("SEALTUN_HOME", t.TempDir())
85+
if err := session.Save(session.TunnelSession{
86+
TunnelID: "cleanlocked",
87+
ConnectionState: session.ConnectionStateStopped,
88+
CreatedAt: time.Now().Format(time.RFC3339),
89+
}); err != nil {
90+
t.Fatalf("save session: %v", err)
91+
}
92+
93+
releaseLock := holdTunnelOperationLock(t, "cleanlocked")
94+
defer releaseLock()
95+
previousCleanup := cleanupSessionResources
96+
cleanupCalled := make(chan struct{}, 1)
97+
want := fmt.Errorf("stop after lock")
98+
cleanupSessionResources = func(context.Context, session.TunnelSession) error {
99+
cleanupCalled <- struct{}{}
100+
return want
101+
}
102+
t.Cleanup(func() { cleanupSessionResources = previousCleanup })
103+
104+
done := make(chan error, 1)
105+
go func() {
106+
cmd := *cleanupCmd
107+
cmd.SetContext(context.Background())
108+
done <- cmd.RunE(&cmd, []string{"cleanlocked"})
109+
}()
110+
assertOperationBlocked(t, cleanupCalled)
111+
releaseLock()
112+
if err := <-done; !errors.Is(err, want) {
113+
t.Fatalf("cleanup error = %v, want %v", err, want)
114+
}
115+
}

cmd/connect.go

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"fmt"
77
"os"
88
"os/signal"
9-
"syscall"
109

1110
"github.com/labring/sealtun/pkg/clusterconnect"
1211
"github.com/spf13/cobra"
@@ -37,6 +36,9 @@ var (
3736
connectSaveState = clusterconnect.SaveState
3837
connectRemoveState = clusterconnect.RemoveState
3938
connectAcquireRuntimeLock = clusterconnect.AcquireRuntimeLock
39+
connectNotifyContext = signal.NotifyContext
40+
connectStopStateProcess = clusterconnect.StopStateProcess
41+
connectCleanupState = clusterconnect.CleanupTransparentState
4042
)
4143

4244
var connectCmd = &cobra.Command{
@@ -99,6 +101,10 @@ func runConnectCheck(cmd *cobra.Command, opts connectOptions) error {
99101
if err != nil {
100102
return err
101103
}
104+
return runConnectCheckWithEnvironment(cmd, opts, env)
105+
}
106+
107+
func runConnectCheckWithEnvironment(cmd *cobra.Command, opts connectOptions, env connectPreflighter) error {
102108
ctx := connectCommandContext(cmd)
103109
preflight, err := env.Preflight(ctx, clusterconnect.Options{
104110
Mode: opts.Mode,
@@ -107,16 +113,12 @@ func runConnectCheck(cmd *cobra.Command, opts connectOptions) error {
107113
if opts.JSON {
108114
enc := json.NewEncoder(cmd.OutOrStdout())
109115
enc.SetIndent("", " ")
110-
_ = enc.Encode(preflight)
111-
if preflight != nil {
112-
return nil
116+
if encodeErr := enc.Encode(preflight); encodeErr != nil {
117+
return encodeErr
113118
}
114119
return err
115120
}
116121
printConnectPreflight(cmd, preflight)
117-
if preflight != nil {
118-
return nil
119-
}
120122
return err
121123
}
122124

@@ -183,7 +185,7 @@ func runConnectWithEnvironment(cmd *cobra.Command, opts connectOptions, env conn
183185
}
184186
defer connectRemoveState()
185187

186-
connectCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
188+
connectCtx, stopSignals := connectNotifyContext(ctx, signalCleanupSignals()...)
187189
defer stopSignals()
188190
err = server.RunPlan(connectCtx, plan)
189191
if err == nil || err == context.Canceled {
@@ -254,17 +256,17 @@ func runDisconnect(cmd *cobra.Command) error {
254256
}
255257
return err
256258
}
257-
stopErr := clusterconnect.StopStateProcess(*state)
259+
stopErr := connectStopStateProcess(*state)
260+
if stopErr != nil {
261+
return stopErr
262+
}
258263
plan := &clusterconnect.TransparentPlan{
259264
Namespace: state.Namespace,
260265
Listen: state.Listen,
261266
Rules: state.Rules,
262267
Hosts: state.Hosts,
263268
}
264-
cleanupErr := clusterconnect.CleanupTransparentState(plan)
265-
if stopErr != nil {
266-
return stopErr
267-
}
269+
cleanupErr := connectCleanupState(plan)
268270
if cleanupErr != nil {
269271
return cleanupErr
270272
}

cmd/connect_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"io"
7+
"os"
78
"reflect"
89
"testing"
910

@@ -131,6 +132,96 @@ func TestRunConnectRejectsConcurrentRuntime(t *testing.T) {
131132
}
132133
}
133134

135+
func TestRunConnectCheckReturnsPreflightErrorWithPayload(t *testing.T) {
136+
want := errors.New("no compatible connect mode")
137+
for _, asJSON := range []bool{false, true} {
138+
t.Run(map[bool]string{false: "text", true: "json"}[asJSON], func(t *testing.T) {
139+
env := &fakeConnectEnv{
140+
preflight: &clusterconnect.Preflight{Mode: clusterconnect.ModeAuto},
141+
err: want,
142+
}
143+
err := runConnectCheckWithEnvironment(newTestConnectCommand(), connectOptions{JSON: asJSON}, env)
144+
if !errors.Is(err, want) {
145+
t.Fatalf("runConnectCheckWithEnvironment error = %v, want %v", err, want)
146+
}
147+
})
148+
}
149+
}
150+
151+
func TestRunConnectUsesCleanupSignalSet(t *testing.T) {
152+
stubConnectRuntimeLock(t)
153+
previousNotify := connectNotifyContext
154+
previousSave := connectSaveState
155+
previousRemove := connectRemoveState
156+
t.Cleanup(func() {
157+
connectNotifyContext = previousNotify
158+
connectSaveState = previousSave
159+
connectRemoveState = previousRemove
160+
})
161+
162+
var gotSignals []os.Signal
163+
connectNotifyContext = func(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) {
164+
gotSignals = append([]os.Signal(nil), signals...)
165+
return context.WithCancel(parent)
166+
}
167+
connectSaveState = func(clusterconnect.State) error { return nil }
168+
connectRemoveState = func() error { return nil }
169+
170+
events := []string{}
171+
server := &fakeConnectServer{
172+
plan: &clusterconnect.TransparentPlan{},
173+
events: &events,
174+
}
175+
env := &fakeConnectEnv{preflight: &clusterconnect.Preflight{SelectedMode: clusterconnect.ModeTun}}
176+
if err := runConnectWithEnvironment(newTestConnectCommand(), connectOptions{}, env, func(clusterconnect.TransparentOptions) connectPlanRunner {
177+
return server
178+
}); err != nil {
179+
t.Fatalf("runConnectWithEnvironment returned error: %v", err)
180+
}
181+
if want := signalCleanupSignals(); !reflect.DeepEqual(gotSignals, want) {
182+
t.Fatalf("cleanup signals = %v, want %v", gotSignals, want)
183+
}
184+
}
185+
186+
func TestRunDisconnectDoesNotCleanupWhenStopFails(t *testing.T) {
187+
t.Setenv("SEALTUN_HOME", t.TempDir())
188+
if err := clusterconnect.SaveState(clusterconnect.State{Mode: clusterconnect.ModeTun}); err != nil {
189+
t.Fatalf("save connect state: %v", err)
190+
}
191+
192+
previousStop := connectStopStateProcess
193+
previousCleanup := connectCleanupState
194+
previousRemove := connectRemoveState
195+
t.Cleanup(func() {
196+
connectStopStateProcess = previousStop
197+
connectCleanupState = previousCleanup
198+
connectRemoveState = previousRemove
199+
})
200+
want := errors.New("connect process did not stop")
201+
connectStopStateProcess = func(clusterconnect.State) error { return want }
202+
cleanupCalled := false
203+
connectCleanupState = func(*clusterconnect.TransparentPlan) error {
204+
cleanupCalled = true
205+
return nil
206+
}
207+
removeCalled := false
208+
connectRemoveState = func() error {
209+
removeCalled = true
210+
return nil
211+
}
212+
213+
err := runDisconnect(newTestConnectCommand())
214+
if !errors.Is(err, want) {
215+
t.Fatalf("runDisconnect error = %v, want %v", err, want)
216+
}
217+
if cleanupCalled {
218+
t.Fatal("transparent state was cleaned before the connect process stopped")
219+
}
220+
if removeCalled {
221+
t.Fatal("connect state was removed after the process failed to stop")
222+
}
223+
}
224+
134225
func stubConnectRuntimeLock(t *testing.T) {
135226
t.Helper()
136227
previous := connectAcquireRuntimeLock

0 commit comments

Comments
 (0)