Skip to content

Commit f0d4135

Browse files
committed
fix: stop command detects orphaned resources when lock file missing
1 parent aa3dedf commit f0d4135

7 files changed

Lines changed: 159 additions & 56 deletions

File tree

internal/core/application/tunnel/handle-dns-record.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ type DNSRecordHandler struct {
3030
dnsService ports.DNSService
3131
}
3232

33-
// Ensure DNSRecordHandler implements the typed interface
33+
// Ensure DNSRecordHandler implements the required interfaces
3434
var _ framework.ResourceHandler[DNSRecordConfig, DNSRecordState] = (*DNSRecordHandler)(nil)
3535

3636
func newDNSRecordHandler(dnsService ports.DNSService) *DNSRecordHandler {
@@ -83,7 +83,7 @@ func (h *DNSRecordHandler) Destroy(ctx context.Context, state DNSRecordState) er
8383
return nil
8484
}
8585

86-
func (h *DNSRecordHandler) Status(ctx context.Context, state DNSRecordState) (domain.State, error) {
86+
func (h *DNSRecordHandler) CheckFromState(ctx context.Context, state DNSRecordState) (domain.State, error) {
8787
exists, err := h.dnsService.RecordExists(ctx, state.Tunnel, state.Zone, state.Subdomain)
8888
if err != nil {
8989
return domain.StateDown, shared.WrapError(err, "failed to check DNS record existence")
@@ -100,3 +100,22 @@ func (h *DNSRecordHandler) Equals(a, b DNSRecordConfig) bool {
100100
a.Subdomain == b.Subdomain &&
101101
a.Tunnel.ID == b.Tunnel.ID
102102
}
103+
104+
// CheckFromConfig finds existing DNS record from config and returns state + status
105+
func (h *DNSRecordHandler) CheckFromConfig(ctx context.Context, config DNSRecordConfig) (DNSRecordState, domain.State, error) {
106+
exists, err := h.dnsService.RecordExists(ctx, config.Tunnel, config.Zone, config.Subdomain)
107+
if err != nil {
108+
return DNSRecordState{}, domain.StateDown, shared.WrapError(err, "failed to check DNS record existence")
109+
}
110+
111+
state := DNSRecordState{
112+
Zone: config.Zone,
113+
Tunnel: config.Tunnel,
114+
Subdomain: config.Subdomain,
115+
}
116+
117+
if exists {
118+
return state, domain.StateUp, nil
119+
}
120+
return state, domain.StateDown, nil
121+
}

internal/core/application/tunnel/handle-tunnel-config.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ type TunnelConfigHandler struct {
3030
tunnelService ports.TunnelService
3131
}
3232

33-
// Ensure TunnelConfigHandler implements the typed interface
33+
// Ensure TunnelConfigHandler implements the required interfaces
3434
var _ framework.ResourceHandler[TunnelConfigConfig, TunnelConfigState] = (*TunnelConfigHandler)(nil)
3535

3636
func newTunnelConfigHandler(tunnelService ports.TunnelService) *TunnelConfigHandler {
@@ -80,7 +80,7 @@ func (h *TunnelConfigHandler) Destroy(ctx context.Context, state TunnelConfigSta
8080
return nil
8181
}
8282

83-
func (h *TunnelConfigHandler) Status(ctx context.Context, state TunnelConfigState) (domain.State, error) {
83+
func (h *TunnelConfigHandler) CheckFromState(ctx context.Context, state TunnelConfigState) (domain.State, error) {
8484
if _, err := os.Stat(state.ConfigPath); err != nil {
8585
if os.IsNotExist(err) {
8686
return domain.StateDown, nil
@@ -95,3 +95,28 @@ func (h *TunnelConfigHandler) Equals(a, b TunnelConfigConfig) bool {
9595
return a.Tunnel.ID == b.Tunnel.ID &&
9696
reflect.DeepEqual(a.Ingress, b.Ingress)
9797
}
98+
99+
// CheckFromConfig finds existing tunnel configuration from config and returns state + status
100+
func (h *TunnelConfigHandler) CheckFromConfig(ctx context.Context, config TunnelConfigConfig) (TunnelConfigState, domain.State, error) {
101+
configPath, err := h.tunnelService.GetConfigurationPath(ctx, config.Tunnel)
102+
if err != nil {
103+
return TunnelConfigState{}, domain.StateDown, shared.WrapError(err, "failed to get configuration path")
104+
}
105+
106+
// Check if config file exists
107+
if _, err := os.Stat(configPath); err != nil {
108+
if os.IsNotExist(err) {
109+
return TunnelConfigState{}, domain.StateDown, nil
110+
}
111+
return TunnelConfigState{}, domain.StateDown, shared.WrapError(err, "failed to check configuration file")
112+
}
113+
114+
// Convert config to state
115+
state := TunnelConfigState{
116+
Tunnel: config.Tunnel,
117+
Ingress: config.Ingress,
118+
ConfigPath: configPath,
119+
}
120+
121+
return state, domain.StateUp, nil
122+
}

internal/core/application/tunnel/handle-tunnel-create.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ type TunnelCreateHandler struct {
2525
tunnelService ports.TunnelService
2626
}
2727

28-
// Ensure TunnelCreateHandler implements the typed interface
28+
// Ensure TunnelCreateHandler implements the required interfaces
2929
var _ framework.ResourceHandler[TunnelCreateConfig, TunnelCreateState] = (*TunnelCreateHandler)(nil)
3030

3131
func newTunnelCreateHandler(tunnelService ports.TunnelService) *TunnelCreateHandler {
@@ -65,7 +65,7 @@ func (h *TunnelCreateHandler) Destroy(ctx context.Context, state TunnelCreateSta
6565
return nil
6666
}
6767

68-
func (h *TunnelCreateHandler) Status(ctx context.Context, state TunnelCreateState) (domain.State, error) {
68+
func (h *TunnelCreateHandler) CheckFromState(ctx context.Context, state TunnelCreateState) (domain.State, error) {
6969
exists, err := h.tunnelService.TunnelExists(ctx, state.Tunnel)
7070
if err != nil {
7171
return domain.StateDown, shared.WrapError(err, "failed to check tunnel existence")
@@ -80,3 +80,20 @@ func (h *TunnelCreateHandler) Status(ctx context.Context, state TunnelCreateStat
8080
func (h *TunnelCreateHandler) Equals(a, b TunnelCreateConfig) bool {
8181
return a.Tunnel.ID == b.Tunnel.ID
8282
}
83+
84+
// CheckFromConfig finds existing tunnel from config and returns state + status
85+
func (h *TunnelCreateHandler) CheckFromConfig(ctx context.Context, config TunnelCreateConfig) (TunnelCreateState, domain.State, error) {
86+
exists, err := h.tunnelService.TunnelExists(ctx, config.Tunnel)
87+
if err != nil {
88+
return TunnelCreateState{}, domain.StateDown, shared.WrapError(err, "failed to check tunnel existence")
89+
}
90+
91+
state := TunnelCreateState{
92+
Tunnel: config.Tunnel,
93+
}
94+
95+
if exists {
96+
return state, domain.StateUp, nil
97+
}
98+
return state, domain.StateDown, nil
99+
}

internal/core/application/tunnel/handle-tunnel-run.go

Lines changed: 27 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type TunnelRunHandler struct {
2929
tunnelService ports.TunnelService
3030
}
3131

32-
// Ensure TunnelRunHandler implements the typed interface
32+
// Ensure TunnelRunHandler implements the required interfaces
3333
var _ framework.ResourceHandler[TunnelRunConfig, TunnelRunState] = (*TunnelRunHandler)(nil)
3434

3535
func newTunnelRunHandler(tunnelService ports.TunnelService) *TunnelRunHandler {
@@ -67,8 +67,14 @@ func (h *TunnelRunHandler) Destroy(ctx context.Context, state TunnelRunState) er
6767
"pid": state.PID,
6868
})
6969

70-
if err := h.stopProcess(state.PID); err != nil {
71-
return shared.WrapError(err, "failed to stop tunnel process")
70+
process, err := os.FindProcess(state.PID)
71+
if err != nil {
72+
return fmt.Errorf("failed to find process %d: %w", state.PID, err)
73+
}
74+
75+
// Try graceful termination first
76+
if err := process.Signal(syscall.SIGTERM); err != nil {
77+
return fmt.Errorf("failed to send SIGTERM to process %d: %w", state.PID, err)
7278
}
7379

7480
logger.Infof("Tunnel process stopped", map[string]any{
@@ -78,44 +84,32 @@ func (h *TunnelRunHandler) Destroy(ctx context.Context, state TunnelRunState) er
7884
return nil
7985
}
8086

81-
func (h *TunnelRunHandler) Status(ctx context.Context, state TunnelRunState) (domain.State, error) {
82-
return h.checkProcessStatus(state.PID), nil
83-
}
84-
85-
func (h *TunnelRunHandler) Equals(a, b TunnelRunConfig) bool {
86-
return a.Tunnel.ID == b.Tunnel.ID
87-
}
88-
89-
// stopProcess gracefully stops a process with fallback to force kill
90-
func (h *TunnelRunHandler) stopProcess(pid int) error {
91-
process, err := os.FindProcess(pid)
87+
func (h *TunnelRunHandler) CheckFromState(ctx context.Context, state TunnelRunState) (domain.State, error) {
88+
process, err := os.FindProcess(state.PID)
9289
if err != nil {
93-
return fmt.Errorf("failed to find process %d: %w", pid, err)
90+
return domain.StateDown, nil
9491
}
9592

96-
// Try graceful termination first
97-
if err := process.Signal(syscall.SIGTERM); err != nil {
98-
// Process might already be dead
99-
if h.checkProcessStatus(pid) == domain.StateDown {
100-
return nil
101-
}
102-
return fmt.Errorf("failed to send SIGTERM to process %d: %w", pid, err)
93+
// Send signal 0 to check if process exists and is accessible
94+
if err := process.Signal(syscall.Signal(0)); err != nil {
95+
return domain.StateDown, nil
10396
}
10497

105-
return nil
98+
return domain.StateUp, nil
10699
}
107100

108-
// checkProcessStatus checks if a process is running using signal 0
109-
func (h *TunnelRunHandler) checkProcessStatus(pid int) domain.State {
110-
process, err := os.FindProcess(pid)
111-
if err != nil {
112-
return domain.StateDown
113-
}
101+
// CheckFromConfig finds existing tunnel process from config and returns state + status
102+
func (h *TunnelRunHandler) CheckFromConfig(ctx context.Context, config TunnelRunConfig) (TunnelRunState, domain.State, error) {
103+
pid := 0 // TODO: Implement a way to retrieve the PID of the running tunnel process if needed
114104

115-
// Send signal 0 to check if process exists and is accessible
116-
if err := process.Signal(syscall.Signal(0)); err != nil {
117-
return domain.StateDown
105+
state := TunnelRunState{
106+
PID: pid,
107+
Tunnel: config.Tunnel,
118108
}
119109

120-
return domain.StateUp
110+
return state, domain.StateUp, nil
111+
}
112+
113+
func (h *TunnelRunHandler) Equals(a, b TunnelRunConfig) bool {
114+
return a.Tunnel.ID == b.Tunnel.ID
121115
}

internal/platform/framework/resource-handler.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,34 @@ import (
1111
// TConfig represents the desired configuration (immutable input)
1212
// TState represents the runtime state after resource creation (mutable state)
1313
type ResourceHandler[TConfig any, TState any] interface {
14-
// Name returns the handler name for identification
15-
Name() string
16-
14+
ResourceNamer
15+
ResourceChecker[TConfig, TState]
16+
ResourceCreator[TConfig, TState]
17+
ResourceDestroyer[TState]
1718
// Equals compares two configurations for equality
1819
// Used to detect when resources need to be updated
1920
Equals(a, b TConfig) bool
21+
}
22+
23+
type ResourceNamer interface {
24+
// Name returns the resource name for identification
25+
Name() string
26+
}
2027

28+
type ResourceCreator[TConfig any, TState any] interface {
2129
// Create provisions the resource using the given configuration
2230
// Returns the runtime state needed to manage the resource
2331
Create(ctx context.Context, config TConfig) (TState, error)
32+
}
2433

34+
type ResourceDestroyer[TState any] interface {
2535
// Destroy removes the resource using its runtime state
2636
Destroy(ctx context.Context, state TState) error
37+
}
2738

28-
// Status checks the current state of the resource (idempotent)
29-
Status(ctx context.Context, state TState) (domain.State, error)
39+
type ResourceChecker[TConfig any, TState any] interface {
40+
// CheckFromState checks the resource status from its runtime state
41+
CheckFromState(ctx context.Context, state TState) (domain.State, error)
42+
// CheckFromConfig checks the resource status from its configuration
43+
CheckFromConfig(ctx context.Context, config TConfig) (TState, domain.State, error)
3044
}

internal/platform/framework/resource-manager.go

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ func (rm *ResourceManager[TConfig, TState]) computeActions(
102102
toRemove []ResourceRecord[TConfig, TState],
103103
toAdd []TConfig,
104104
toUpdate []struct {
105-
old ResourceRecord[TConfig, TState]
106105
new TConfig
106+
old ResourceRecord[TConfig, TState]
107107
},
108108
) {
109109
// Build map of current configs
@@ -133,8 +133,8 @@ func (rm *ResourceManager[TConfig, TState]) computeActions(
133133
// Check if config changed
134134
if !rm.handler.Equals(currentRecord.Config, desiredConfig) {
135135
toUpdate = append(toUpdate, struct {
136-
old ResourceRecord[TConfig, TState]
137136
new TConfig
137+
old ResourceRecord[TConfig, TState]
138138
}{old: currentRecord, new: desiredConfig})
139139
}
140140
// If config unchanged, no action needed
@@ -193,7 +193,7 @@ func (rm *ResourceManager[TConfig, TState]) addResources(
193193
}
194194

195195
// Verify it's actually up
196-
status, err := rm.handler.Status(ctx, state)
196+
status, err := rm.handler.CheckFromState(ctx, state)
197197
if err != nil {
198198
return shared.WrapError(err, "failed to verify resource status")
199199
}
@@ -204,8 +204,8 @@ func (rm *ResourceManager[TConfig, TState]) addResources(
204204

205205
// Add to persistent storage
206206
record := ResourceRecord[TConfig, TState]{
207-
Config: config,
208207
State: state,
208+
Config: config,
209209
}
210210
if err := rm.addToRegistry(record); err != nil {
211211
return shared.WrapError(err, "failed to add to registry")
@@ -218,8 +218,8 @@ func (rm *ResourceManager[TConfig, TState]) addResources(
218218
func (rm *ResourceManager[TConfig, TState]) updateResources(
219219
ctx context.Context,
220220
toUpdate []struct {
221-
old ResourceRecord[TConfig, TState]
222221
new TConfig
222+
old ResourceRecord[TConfig, TState]
223223
},
224224
) error {
225225
for _, update := range toUpdate {
@@ -239,7 +239,7 @@ func (rm *ResourceManager[TConfig, TState]) updateResources(
239239
}
240240

241241
// Verify it's up
242-
status, err := rm.handler.Status(ctx, newState)
242+
status, err := rm.handler.CheckFromState(ctx, newState)
243243
if err != nil {
244244
return shared.WrapError(err, "failed to verify updated resource status")
245245
}
@@ -290,14 +290,48 @@ func (rm *ResourceManager[TConfig, TState]) removeFromRegistry(record ResourceRe
290290
return rm.registry.Remove(persistentEntry)
291291
}
292292

293-
// Stop removes all resources managed by this typed manager
294-
func (rm *ResourceManager[TConfig, TState]) Stop(ctx context.Context) error {
293+
// Stop removes all resources managed by this typed manager (tracked + detected)
294+
func (rm *ResourceManager[TConfig, TState]) Stop(ctx context.Context, configs []TConfig) error {
295+
// Get currently tracked resources
295296
currentRecords := rm.getCurrentRecords()
296297

297-
logger.Debugf("Stop found resources", map[string]any{
298+
// Create a map of tracked resources for efficient lookup
299+
trackedMap := make(map[string]ResourceRecord[TConfig, TState])
300+
for _, record := range currentRecords {
301+
key := rm.getConfigKey(record.Config)
302+
trackedMap[key] = record
303+
}
304+
305+
// Collect all resources to remove (tracked + detected)
306+
allResourcesToRemove := currentRecords
307+
308+
// Check for untracked resources matching configs
309+
for _, config := range configs {
310+
key := rm.getConfigKey(config)
311+
312+
// Skip if already tracked
313+
if _, exists := trackedMap[key]; exists {
314+
continue
315+
}
316+
317+
// Try to check untracked resource directly from config
318+
if state, status, err := rm.handler.CheckFromConfig(ctx, config); err == nil && status == domain.StateUp {
319+
logger.Infof("Found untracked running resource", map[string]any{
320+
"handler": rm.handlerName,
321+
})
322+
323+
record := ResourceRecord[TConfig, TState]{
324+
Config: config,
325+
State: state,
326+
}
327+
allResourcesToRemove = append(allResourcesToRemove, record)
328+
}
329+
}
330+
331+
logger.Debugf("Stop found total resources", map[string]any{
298332
"handler": rm.handlerName,
299-
"count": len(currentRecords),
333+
"count": len(allResourcesToRemove),
300334
})
301335

302-
return rm.removeResources(ctx, currentRecords)
336+
return rm.removeResources(ctx, allResourcesToRemove)
303337
}

internal/platform/framework/resource-orchestrator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,5 +104,5 @@ func (tro *ReconcileOperation[TConfig, TState]) Name() string {
104104
}
105105

106106
func (tro *ReconcileOperation[TConfig, TState]) Stop(ctx context.Context) error {
107-
return tro.manager.Stop(ctx)
107+
return tro.manager.Stop(ctx, tro.configs)
108108
}

0 commit comments

Comments
 (0)