detached 33a66ec

fix(cleanup): keep route delete response DTO scoped

commit
33a66ecb9cdce1de4ec248fb68a490f48b07e45e
author
brice · · 2026-05-22 04:10 UTC
parent
6e7b92e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +188 -69
M internal/adapters/dto/admin_route.go +2 -4
@@ -1,8 +1,6 @@
11 // Package dto provides shared data transfer objects for API responses.
22 package dto
33
4-import "github.com/bnema/gordon/internal/domain"
5-
64 // Route represents route configuration in API responses.
75 type Route struct {
86 Domain string `json:"domain"`
@@ -52,6 +50,6 @@ type RoutesByImageResponse struct {
5250
5351 // RouteDeleteResponse represents a route removal response.
5452 type RouteDeleteResponse struct {
55- Status string `json:"status"`
56- Cleanup *domain.CleanupReport `json:"cleanup,omitempty"`
53+ Status string `json:"status"`
54+ Cleanup *CleanupReport `json:"cleanup,omitempty"`
5755 }
@@ -1,8 +1,6 @@
1 // Package dto provides shared data transfer objects for API responses.1 // Package dto provides shared data transfer objects for API responses.
2 package dto2 package dto
3 3
4-import "github.com/bnema/gordon/internal/domain"
5-
6 // Route represents route configuration in API responses.4 // Route represents route configuration in API responses.
7 type Route struct {5 type Route struct {
8 Domain string `json:"domain"`6 Domain string `json:"domain"`
@@ -52,6 +50,6 @@ type RoutesByImageResponse struct {
52 50
53 // RouteDeleteResponse represents a route removal response.51 // RouteDeleteResponse represents a route removal response.
54 type RouteDeleteResponse struct {52 type RouteDeleteResponse struct {
55- Status string `json:"status"`53+ Status string `json:"status"`
56- Cleanup *domain.CleanupReport `json:"cleanup,omitempty"`54+ Cleanup *CleanupReport `json:"cleanup,omitempty"`
57 }55 }
A internal/adapters/dto/cleanup.go +86 -0
@@ -0,0 +1,86 @@
1+package dto
2+
3+import "github.com/bnema/gordon/internal/domain"
4+
5+// CleanupReport describes cleanup results in API responses.
6+type CleanupReport struct {
7+ Domain string `json:"domain"`
8+ RemovedContainers []CleanupContainer `json:"removed_containers"`
9+ PreservedVolumes []CleanupVolume `json:"preserved_volumes"`
10+ PreservedAttachments []CleanupAttachment `json:"preserved_attachments"`
11+ OrphanedEntities []CleanupOrphaned `json:"orphaned_entities"`
12+ Warnings []string `json:"warnings"`
13+ Hints []string `json:"hints"`
14+ PartialFailures []CleanupFailure `json:"partial_failures"`
15+}
16+
17+// CleanupContainer identifies a container affected by cleanup.
18+type CleanupContainer struct {
19+ ID string `json:"id"`
20+ Name string `json:"name"`
21+ Image string `json:"image"`
22+ Status string `json:"status"`
23+}
24+
25+// CleanupVolume identifies preserved stateful data.
26+type CleanupVolume struct {
27+ Name string `json:"name"`
28+ ContainerPath string `json:"container_path,omitempty"`
29+ Reason string `json:"reason,omitempty"`
30+}
31+
32+// CleanupAttachment identifies an attachment retained for follow-up cleanup.
33+type CleanupAttachment struct {
34+ Name string `json:"name"`
35+ Image string `json:"image"`
36+ ContainerID string `json:"container_id"`
37+ Status string `json:"status"`
38+ Owner string `json:"owner,omitempty"`
39+ Reason string `json:"reason,omitempty"`
40+}
41+
42+// CleanupOrphaned identifies runtime state no longer configured.
43+type CleanupOrphaned struct {
44+ Kind string `json:"kind"`
45+ ID string `json:"id,omitempty"`
46+ Name string `json:"name,omitempty"`
47+ Status string `json:"status,omitempty"`
48+ Reason string `json:"reason,omitempty"`
49+}
50+
51+// CleanupFailure records non-fatal cleanup failures.
52+type CleanupFailure struct {
53+ Action string `json:"action"`
54+ Kind string `json:"kind"`
55+ ID string `json:"id,omitempty"`
56+ Name string `json:"name,omitempty"`
57+ Error string `json:"error"`
58+}
59+
60+// CleanupReportFromDomain maps a domain cleanup report to an API DTO.
61+func CleanupReportFromDomain(report *domain.CleanupReport) *CleanupReport {
62+ if report == nil {
63+ return nil
64+ }
65+ out := &CleanupReport{
66+ Domain: report.Domain,
67+ Warnings: append([]string(nil), report.Warnings...),
68+ Hints: append([]string(nil), report.Hints...),
69+ }
70+ for _, item := range report.RemovedContainers {
71+ out.RemovedContainers = append(out.RemovedContainers, CleanupContainer(item))
72+ }
73+ for _, item := range report.PreservedVolumes {
74+ out.PreservedVolumes = append(out.PreservedVolumes, CleanupVolume(item))
75+ }
76+ for _, item := range report.PreservedAttachments {
77+ out.PreservedAttachments = append(out.PreservedAttachments, CleanupAttachment(item))
78+ }
79+ for _, item := range report.OrphanedEntities {
80+ out.OrphanedEntities = append(out.OrphanedEntities, CleanupOrphaned(item))
81+ }
82+ for _, item := range report.PartialFailures {
83+ out.PartialFailures = append(out.PartialFailures, CleanupFailure(item))
84+ }
85+ return out
86+}
@@ -0,0 +1,86 @@
1+package dto
2+
3+import "github.com/bnema/gordon/internal/domain"
4+
5+// CleanupReport describes cleanup results in API responses.
6+type CleanupReport struct {
7+ Domain string `json:"domain"`
8+ RemovedContainers []CleanupContainer `json:"removed_containers"`
9+ PreservedVolumes []CleanupVolume `json:"preserved_volumes"`
10+ PreservedAttachments []CleanupAttachment `json:"preserved_attachments"`
11+ OrphanedEntities []CleanupOrphaned `json:"orphaned_entities"`
12+ Warnings []string `json:"warnings"`
13+ Hints []string `json:"hints"`
14+ PartialFailures []CleanupFailure `json:"partial_failures"`
15+}
16+
17+// CleanupContainer identifies a container affected by cleanup.
18+type CleanupContainer struct {
19+ ID string `json:"id"`
20+ Name string `json:"name"`
21+ Image string `json:"image"`
22+ Status string `json:"status"`
23+}
24+
25+// CleanupVolume identifies preserved stateful data.
26+type CleanupVolume struct {
27+ Name string `json:"name"`
28+ ContainerPath string `json:"container_path,omitempty"`
29+ Reason string `json:"reason,omitempty"`
30+}
31+
32+// CleanupAttachment identifies an attachment retained for follow-up cleanup.
33+type CleanupAttachment struct {
34+ Name string `json:"name"`
35+ Image string `json:"image"`
36+ ContainerID string `json:"container_id"`
37+ Status string `json:"status"`
38+ Owner string `json:"owner,omitempty"`
39+ Reason string `json:"reason,omitempty"`
40+}
41+
42+// CleanupOrphaned identifies runtime state no longer configured.
43+type CleanupOrphaned struct {
44+ Kind string `json:"kind"`
45+ ID string `json:"id,omitempty"`
46+ Name string `json:"name,omitempty"`
47+ Status string `json:"status,omitempty"`
48+ Reason string `json:"reason,omitempty"`
49+}
50+
51+// CleanupFailure records non-fatal cleanup failures.
52+type CleanupFailure struct {
53+ Action string `json:"action"`
54+ Kind string `json:"kind"`
55+ ID string `json:"id,omitempty"`
56+ Name string `json:"name,omitempty"`
57+ Error string `json:"error"`
58+}
59+
60+// CleanupReportFromDomain maps a domain cleanup report to an API DTO.
61+func CleanupReportFromDomain(report *domain.CleanupReport) *CleanupReport {
62+ if report == nil {
63+ return nil
64+ }
65+ out := &CleanupReport{
66+ Domain: report.Domain,
67+ Warnings: append([]string(nil), report.Warnings...),
68+ Hints: append([]string(nil), report.Hints...),
69+ }
70+ for _, item := range report.RemovedContainers {
71+ out.RemovedContainers = append(out.RemovedContainers, CleanupContainer(item))
72+ }
73+ for _, item := range report.PreservedVolumes {
74+ out.PreservedVolumes = append(out.PreservedVolumes, CleanupVolume(item))
75+ }
76+ for _, item := range report.PreservedAttachments {
77+ out.PreservedAttachments = append(out.PreservedAttachments, CleanupAttachment(item))
78+ }
79+ for _, item := range report.OrphanedEntities {
80+ out.OrphanedEntities = append(out.OrphanedEntities, CleanupOrphaned(item))
81+ }
82+ for _, item := range report.PartialFailures {
83+ out.PartialFailures = append(out.PartialFailures, CleanupFailure(item))
84+ }
85+ return out
86+}
M internal/adapters/in/cli/controlplane_local.go +1 -1
@@ -149,7 +149,7 @@ func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDom
149149 if err != nil {
150150 return nil, fmt.Errorf("failed to cleanup removed route runtime state: %w", err)
151151 }
152- resp.Cleanup = report
152+ resp.Cleanup = dto.CleanupReportFromDomain(report)
153153 return resp, nil
154154 }
155155
@@ -149,7 +149,7 @@ func (l *localControlPlane) RemoveRouteWithCleanup(ctx context.Context, routeDom
149 if err != nil {149 if err != nil {
150 return nil, fmt.Errorf("failed to cleanup removed route runtime state: %w", err)150 return nil, fmt.Errorf("failed to cleanup removed route runtime state: %w", err)
151 }151 }
152- resp.Cleanup = report152+ resp.Cleanup = dto.CleanupReportFromDomain(report)
153 return resp, nil153 return resp, nil
154 }154 }
155 155
M internal/adapters/in/cli/controlplane_local_test.go +1 -1
@@ -229,5 +229,5 @@ func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t
229229 resp, err := cp.RemoveRouteWithCleanup(ctx, "app.local")
230230 require.NoError(t, err)
231231 require.NotNil(t, resp)
232- assert.Equal(t, report, resp.Cleanup)
232+ assert.Equal(t, dto.CleanupReportFromDomain(report), resp.Cleanup)
233233 }
@@ -229,5 +229,5 @@ func TestLocalControlPlane_RemoveRouteReconcilesRuntimeWhenRouteAlreadyMissing(t
229 resp, err := cp.RemoveRouteWithCleanup(ctx, "app.local")229 resp, err := cp.RemoveRouteWithCleanup(ctx, "app.local")
230 require.NoError(t, err)230 require.NoError(t, err)
231 require.NotNil(t, resp)231 require.NotNil(t, resp)
232- assert.Equal(t, report, resp.Cleanup)232+ assert.Equal(t, dto.CleanupReportFromDomain(report), resp.Cleanup)
233 }233 }
M internal/adapters/in/cli/remote/client.go +4 -4
@@ -556,11 +556,11 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
556556 func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
557557 resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
558558 if err != nil {
559- return nil, err
559+ return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err)
560560 }
561561 var result dto.RouteDeleteResponse
562562 if err := parseResponse(resp, &result); err != nil {
563- return nil, err
563+ return nil, fmt.Errorf("parse delete route %s response: %w", routeDomain, err)
564564 }
565565 return &result, nil
566566 }
@@ -1065,11 +1065,11 @@ func (c *Client) CleanupOrphanedAttachments(ctx context.Context, owner string, s
10651065 }
10661066 resp, err := c.request(ctx, http.MethodPost, path, nil)
10671067 if err != nil {
1068- return nil, err
1068+ return nil, fmt.Errorf("cleanup orphaned attachments failed: %w", err)
10691069 }
10701070 var result domain.CleanupReport
10711071 if err := parseResponse(resp, &result); err != nil {
1072- return nil, err
1072+ return nil, fmt.Errorf("parse orphaned attachment cleanup response: %w", err)
10731073 }
10741074 return &result, nil
10751075 }
@@ -556,11 +556,11 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
556 func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {556 func (c *Client) RemoveRouteWithCleanup(ctx context.Context, routeDomain string) (*dto.RouteDeleteResponse, error) {
557 resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)557 resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil)
558 if err != nil {558 if err != nil {
559- return nil, err559+ return nil, fmt.Errorf("delete route %s failed: %w", routeDomain, err)
560 }560 }
561 var result dto.RouteDeleteResponse561 var result dto.RouteDeleteResponse
562 if err := parseResponse(resp, &result); err != nil {562 if err := parseResponse(resp, &result); err != nil {
563- return nil, err563+ return nil, fmt.Errorf("parse delete route %s response: %w", routeDomain, err)
564 }564 }
565 return &result, nil565 return &result, nil
566 }566 }
@@ -1065,11 +1065,11 @@ func (c *Client) CleanupOrphanedAttachments(ctx context.Context, owner string, s
1065 }1065 }
1066 resp, err := c.request(ctx, http.MethodPost, path, nil)1066 resp, err := c.request(ctx, http.MethodPost, path, nil)
1067 if err != nil {1067 if err != nil {
1068- return nil, err1068+ return nil, fmt.Errorf("cleanup orphaned attachments failed: %w", err)
1069 }1069 }
1070 var result domain.CleanupReport1070 var result domain.CleanupReport
1071 if err := parseResponse(resp, &result); err != nil {1071 if err := parseResponse(resp, &result); err != nil {
1072- return nil, err1072+ return nil, fmt.Errorf("parse orphaned attachment cleanup response: %w", err)
1073 }1073 }
1074 return &result, nil1074 return &result, nil
1075 }1075 }
M internal/adapters/in/cli/routes.go +62 -33
@@ -1101,25 +1101,29 @@ func newRoutesDiagnoseCmd() *cobra.Command {
11011101 Short: "Diagnose route configuration, runtime state, and cleanup leftovers",
11021102 Args: cobra.ExactArgs(1),
11031103 RunE: func(cmd *cobra.Command, args []string) error {
1104- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
1105- if err != nil {
1106- return err
1107- }
1108- defer handle.close()
1109- diag, err := buildRouteDiagnosis(cmd.Context(), handle.plane, args[0])
1110- if err != nil {
1111- return err
1112- }
1113- if jsonOut {
1114- return writeJSON(cmd.OutOrStdout(), diag)
1115- }
1116- return writeRouteDiagnosisText(cmd.OutOrStdout(), diag)
1104+ return runRouteDiagnosis(cmd.Context(), args[0], cmd.OutOrStdout(), jsonOut)
11171105 },
11181106 }
11191107 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
11201108 return cmd
11211109 }
11221110
1111+func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error {
1112+ handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1113+ if err != nil {
1114+ return err
1115+ }
1116+ defer handle.close()
1117+ diag, err := buildRouteDiagnosis(ctx, handle.plane, domainName)
1118+ if err != nil {
1119+ return err
1120+ }
1121+ if jsonOut {
1122+ return writeJSON(out, diag)
1123+ }
1124+ return writeRouteDiagnosisText(out, diag)
1125+}
1126+
11231127 func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {
11241128 diag := &routeDiagnosis{Domain: domainName}
11251129 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
@@ -1266,19 +1270,7 @@ func newRoutesPurgeCmd() *cobra.Command {
12661270 Short: "Review or explicitly purge retained route resources",
12671271 Args: cobra.ExactArgs(1),
12681272 RunE: func(cmd *cobra.Command, args []string) error {
1269- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
1270- if err != nil {
1271- return err
1272- }
1273- defer handle.close()
1274- report, err := runRoutePurge(cmd.Context(), handle.plane, args[0], routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes})
1275- if err != nil {
1276- return err
1277- }
1278- if jsonOut {
1279- return writeJSON(cmd.OutOrStdout(), report)
1280- }
1281- return writeRoutePurgeText(cmd.OutOrStdout(), report, force)
1273+ return runRoutePurgeCommand(cmd.Context(), args[0], cmd.OutOrStdout(), routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes}, jsonOut)
12821274 },
12831275 }
12841276 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
@@ -1294,6 +1286,22 @@ type routePurgeOptions struct {
12941286 Volumes bool
12951287 }
12961288
1289+func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error {
1290+ handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1291+ if err != nil {
1292+ return err
1293+ }
1294+ defer handle.close()
1295+ report, err := runRoutePurge(ctx, handle.plane, domainName, opts)
1296+ if err != nil {
1297+ return err
1298+ }
1299+ if jsonOut {
1300+ return writeJSON(out, report)
1301+ }
1302+ return writeRoutePurgeText(out, report, opts.Force)
1303+}
1304+
12971305 func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) {
12981306 diag, err := buildRouteDiagnosis(ctx, cp, domainName)
12991307 if err != nil {
@@ -1303,8 +1311,10 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts
13031311 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
13041312 report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"})
13051313 }
1306- for _, volume := range diag.Volumes {
1307- report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
1314+ if opts.Volumes {
1315+ for _, volume := range diag.Volumes {
1316+ report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
1317+ }
13081318 }
13091319 if !opts.Attachments || !opts.Force {
13101320 report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...)
@@ -1468,7 +1478,7 @@ Examples:
14681478 return err
14691479 }
14701480 defer handle.close()
1471- var cleanup *domain.CleanupReport
1481+ var cleanup *dto.CleanupReport
14721482 if remover, ok := handle.plane.(interface {
14731483 RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error)
14741484 }); ok {
@@ -1492,20 +1502,39 @@ Examples:
14921502 return cmd
14931503 }
14941504
1495-func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *domain.CleanupReport) error {
1505+func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *dto.CleanupReport) error {
14961506 if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil {
14971507 return err
14981508 }
14991509 if cleanup == nil {
15001510 return nil
15011511 }
1502- if err := writeRemovedContainers(out, cleanup.RemovedContainers); err != nil {
1512+ cleanupReport := cleanupReportDTOToDomain(cleanup)
1513+ if err := writeRemovedContainers(out, cleanupReport.RemovedContainers); err != nil {
15031514 return err
15041515 }
1505- if err := writePreservedAttachments(out, cleanup.PreservedAttachments); err != nil {
1516+ if err := writePreservedAttachments(out, cleanupReport.PreservedAttachments); err != nil {
15061517 return err
15071518 }
1508- return writeCleanupMessages(out, cleanup.Warnings, cleanup.Hints)
1519+ return writeCleanupMessages(out, cleanupReport.Warnings, cleanupReport.Hints)
1520+}
1521+
1522+func cleanupReportDTOToDomain(cleanup *dto.CleanupReport) *domain.CleanupReport {
1523+ if cleanup == nil {
1524+ return nil
1525+ }
1526+ out := &domain.CleanupReport{
1527+ Domain: cleanup.Domain,
1528+ Warnings: append([]string(nil), cleanup.Warnings...),
1529+ Hints: append([]string(nil), cleanup.Hints...),
1530+ }
1531+ for _, item := range cleanup.RemovedContainers {
1532+ out.RemovedContainers = append(out.RemovedContainers, domain.CleanupContainer(item))
1533+ }
1534+ for _, item := range cleanup.PreservedAttachments {
1535+ out.PreservedAttachments = append(out.PreservedAttachments, domain.CleanupAttachment(item))
1536+ }
1537+ return out
15091538 }
15101539
15111540 func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error {
@@ -1101,25 +1101,29 @@ func newRoutesDiagnoseCmd() *cobra.Command {
1101 Short: "Diagnose route configuration, runtime state, and cleanup leftovers",1101 Short: "Diagnose route configuration, runtime state, and cleanup leftovers",
1102 Args: cobra.ExactArgs(1),1102 Args: cobra.ExactArgs(1),
1103 RunE: func(cmd *cobra.Command, args []string) error {1103 RunE: func(cmd *cobra.Command, args []string) error {
1104- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])1104+ return runRouteDiagnosis(cmd.Context(), args[0], cmd.OutOrStdout(), jsonOut)
1105- if err != nil {
1106- return err
1107- }
1108- defer handle.close()
1109- diag, err := buildRouteDiagnosis(cmd.Context(), handle.plane, args[0])
1110- if err != nil {
1111- return err
1112- }
1113- if jsonOut {
1114- return writeJSON(cmd.OutOrStdout(), diag)
1115- }
1116- return writeRouteDiagnosisText(cmd.OutOrStdout(), diag)
1117 },1105 },
1118 }1106 }
1119 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")1107 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1120 return cmd1108 return cmd
1121 }1109 }
1122 1110
1111+func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error {
1112+ handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1113+ if err != nil {
1114+ return err
1115+ }
1116+ defer handle.close()
1117+ diag, err := buildRouteDiagnosis(ctx, handle.plane, domainName)
1118+ if err != nil {
1119+ return err
1120+ }
1121+ if jsonOut {
1122+ return writeJSON(out, diag)
1123+ }
1124+ return writeRouteDiagnosisText(out, diag)
1125+}
1126+
1123 func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {1127 func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {
1124 diag := &routeDiagnosis{Domain: domainName}1128 diag := &routeDiagnosis{Domain: domainName}
1125 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)1129 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
@@ -1266,19 +1270,7 @@ func newRoutesPurgeCmd() *cobra.Command {
1266 Short: "Review or explicitly purge retained route resources",1270 Short: "Review or explicitly purge retained route resources",
1267 Args: cobra.ExactArgs(1),1271 Args: cobra.ExactArgs(1),
1268 RunE: func(cmd *cobra.Command, args []string) error {1272 RunE: func(cmd *cobra.Command, args []string) error {
1269- handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])1273+ return runRoutePurgeCommand(cmd.Context(), args[0], cmd.OutOrStdout(), routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes}, jsonOut)
1270- if err != nil {
1271- return err
1272- }
1273- defer handle.close()
1274- report, err := runRoutePurge(cmd.Context(), handle.plane, args[0], routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes})
1275- if err != nil {
1276- return err
1277- }
1278- if jsonOut {
1279- return writeJSON(cmd.OutOrStdout(), report)
1280- }
1281- return writeRoutePurgeText(cmd.OutOrStdout(), report, force)
1282 },1274 },
1283 }1275 }
1284 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")1276 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
@@ -1294,6 +1286,22 @@ type routePurgeOptions struct {
1294 Volumes bool1286 Volumes bool
1295 }1287 }
1296 1288
1289+func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error {
1290+ handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1291+ if err != nil {
1292+ return err
1293+ }
1294+ defer handle.close()
1295+ report, err := runRoutePurge(ctx, handle.plane, domainName, opts)
1296+ if err != nil {
1297+ return err
1298+ }
1299+ if jsonOut {
1300+ return writeJSON(out, report)
1301+ }
1302+ return writeRoutePurgeText(out, report, opts.Force)
1303+}
1304+
1297 func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) {1305 func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) {
1298 diag, err := buildRouteDiagnosis(ctx, cp, domainName)1306 diag, err := buildRouteDiagnosis(ctx, cp, domainName)
1299 if err != nil {1307 if err != nil {
@@ -1303,8 +1311,10 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts
1303 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {1311 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
1304 report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"})1312 report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"})
1305 }1313 }
1306- for _, volume := range diag.Volumes {1314+ if opts.Volumes {
1307- report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})1315+ for _, volume := range diag.Volumes {
1316+ report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
1317+ }
1308 }1318 }
1309 if !opts.Attachments || !opts.Force {1319 if !opts.Attachments || !opts.Force {
1310 report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...)1320 report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...)
@@ -1468,7 +1478,7 @@ Examples:
1468 return err1478 return err
1469 }1479 }
1470 defer handle.close()1480 defer handle.close()
1471- var cleanup *domain.CleanupReport1481+ var cleanup *dto.CleanupReport
1472 if remover, ok := handle.plane.(interface {1482 if remover, ok := handle.plane.(interface {
1473 RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error)1483 RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error)
1474 }); ok {1484 }); ok {
@@ -1492,20 +1502,39 @@ Examples:
1492 return cmd1502 return cmd
1493 }1503 }
1494 1504
1495-func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *domain.CleanupReport) error {1505+func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *dto.CleanupReport) error {
1496 if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil {1506 if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil {
1497 return err1507 return err
1498 }1508 }
1499 if cleanup == nil {1509 if cleanup == nil {
1500 return nil1510 return nil
1501 }1511 }
1502- if err := writeRemovedContainers(out, cleanup.RemovedContainers); err != nil {1512+ cleanupReport := cleanupReportDTOToDomain(cleanup)
1513+ if err := writeRemovedContainers(out, cleanupReport.RemovedContainers); err != nil {
1503 return err1514 return err
1504 }1515 }
1505- if err := writePreservedAttachments(out, cleanup.PreservedAttachments); err != nil {1516+ if err := writePreservedAttachments(out, cleanupReport.PreservedAttachments); err != nil {
1506 return err1517 return err
1507 }1518 }
1508- return writeCleanupMessages(out, cleanup.Warnings, cleanup.Hints)1519+ return writeCleanupMessages(out, cleanupReport.Warnings, cleanupReport.Hints)
1520+}
1521+
1522+func cleanupReportDTOToDomain(cleanup *dto.CleanupReport) *domain.CleanupReport {
1523+ if cleanup == nil {
1524+ return nil
1525+ }
1526+ out := &domain.CleanupReport{
1527+ Domain: cleanup.Domain,
1528+ Warnings: append([]string(nil), cleanup.Warnings...),
1529+ Hints: append([]string(nil), cleanup.Hints...),
1530+ }
1531+ for _, item := range cleanup.RemovedContainers {
1532+ out.RemovedContainers = append(out.RemovedContainers, domain.CleanupContainer(item))
1533+ }
1534+ for _, item := range cleanup.PreservedAttachments {
1535+ out.PreservedAttachments = append(out.PreservedAttachments, domain.CleanupAttachment(item))
1536+ }
1537+ return out
1509 }1538 }
1510 1539
1511 func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error {1540 func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error {
M internal/adapters/in/cli/routes_test.go +2 -1
@@ -13,6 +13,7 @@ import (
1313 "sync/atomic"
1414 "testing"
1515
16+ "github.com/bnema/gordon/internal/adapters/dto"
1617 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
1718 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1819 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
@@ -911,7 +912,7 @@ func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) {
911912 Hints: []string{"run diagnose"},
912913 }
913914
914- err := writeRouteRemoveText(&out, "app.example.com", report)
915+ err := writeRouteRemoveText(&out, "app.example.com", dto.CleanupReportFromDomain(report))
915916 require.NoError(t, err)
916917 rendered := stripANSI(out.String())
917918 assert.Contains(t, rendered, "Route removed: app.example.com")
@@ -13,6 +13,7 @@ import (
13 "sync/atomic"13 "sync/atomic"
14 "testing"14 "testing"
15 15
16+ "github.com/bnema/gordon/internal/adapters/dto"
16 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"17 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
17 "github.com/bnema/gordon/internal/adapters/in/cli/remote"18 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
18 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"19 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
@@ -911,7 +912,7 @@ func TestWriteRouteRemoveTextShowsCleanupReport(t *testing.T) {
911 Hints: []string{"run diagnose"},912 Hints: []string{"run diagnose"},
912 }913 }
913 914
914- err := writeRouteRemoveText(&out, "app.example.com", report)915+ err := writeRouteRemoveText(&out, "app.example.com", dto.CleanupReportFromDomain(report))
915 require.NoError(t, err)916 require.NoError(t, err)
916 rendered := stripANSI(out.String())917 rendered := stripANSI(out.String())
917 assert.Contains(t, rendered, "Route removed: app.example.com")918 assert.Contains(t, rendered, "Route removed: app.example.com")
M internal/adapters/in/http/admin/handler.go +2 -2
@@ -793,7 +793,7 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
793793 }
794794 }
795795
796- var cleanup *domain.CleanupReport
796+ var cleanup *dto.CleanupReport
797797 if h.containerSvc != nil {
798798 report, err := h.containerSvc.ReconcileRemovedRoute(ctx, routeDomain)
799799 if err != nil {
@@ -801,7 +801,7 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
801801 h.sendError(w, http.StatusInternalServerError, "route removed but runtime cleanup failed")
802802 return
803803 }
804- cleanup = report
804+ cleanup = dto.CleanupReportFromDomain(report)
805805 }
806806
807807 log.Info().Str("domain", routeDomain).Msg("route removed")
@@ -793,7 +793,7 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
793 }793 }
794 }794 }
795 795
796- var cleanup *domain.CleanupReport796+ var cleanup *dto.CleanupReport
797 if h.containerSvc != nil {797 if h.containerSvc != nil {
798 report, err := h.containerSvc.ReconcileRemovedRoute(ctx, routeDomain)798 report, err := h.containerSvc.ReconcileRemovedRoute(ctx, routeDomain)
799 if err != nil {799 if err != nil {
@@ -801,7 +801,7 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
801 h.sendError(w, http.StatusInternalServerError, "route removed but runtime cleanup failed")801 h.sendError(w, http.StatusInternalServerError, "route removed but runtime cleanup failed")
802 return802 return
803 }803 }
804- cleanup = report804+ cleanup = dto.CleanupReportFromDomain(report)
805 }805 }
806 806
807 log.Info().Str("domain", routeDomain).Msg("route removed")807 log.Info().Str("domain", routeDomain).Msg("route removed")
M internal/adapters/in/http/admin/handler_test.go +28 -23
@@ -720,8 +720,8 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
720720 for _, tt := range tests {
721721 t.Run(tt.name, func(t *testing.T) {
722722 if tt.wantStatus == http.StatusOK {
723- configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
724- containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Maybe()
723+ configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once()
724+ containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Once()
725725 }
726726
727727 req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
@@ -751,16 +751,17 @@ func TestHandler_RoutesDelete_ReconcilesRuntimeWhenRouteAlreadyMissing(t *testin
751751 d.ContainerSvc = containerSvc
752752 d.SecretSvc = secretSvc
753753 })
754+ server := newScopedTestServer(t, handler, "admin:routes:write")
754755
755- req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
756- req = req.WithContext(ctxWithScopes("admin:routes:write"))
757- rec := httptest.NewRecorder()
758-
759- handler.ServeHTTP(rec, req)
756+ req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil)
757+ require.NoError(t, err)
758+ respHTTP, err := http.DefaultClient.Do(req)
759+ require.NoError(t, err)
760+ defer respHTTP.Body.Close()
760761
761- require.Equal(t, http.StatusOK, rec.Code)
762+ require.Equal(t, http.StatusOK, respHTTP.StatusCode)
762763 var resp dto.RouteDeleteResponse
763- require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
764+ require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp))
764765 require.NotNil(t, resp.Cleanup)
765766 assert.Equal(t, "app.example.com", resp.Cleanup.Domain)
766767 }
@@ -780,15 +781,18 @@ func TestHandler_RoutesDelete_ReturnsServerErrorWhenRuntimeCleanupFails(t *testi
780781 d.ContainerSvc = containerSvc
781782 d.SecretSvc = secretSvc
782783 })
784+ server := newScopedTestServer(t, handler, "admin:routes:write")
783785
784- req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
785- req = req.WithContext(ctxWithScopes("admin:routes:write"))
786- rec := httptest.NewRecorder()
787-
788- handler.ServeHTTP(rec, req)
786+ req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil)
787+ require.NoError(t, err)
788+ resp, err := http.DefaultClient.Do(req)
789+ require.NoError(t, err)
790+ defer resp.Body.Close()
791+ body, err := io.ReadAll(resp.Body)
792+ require.NoError(t, err)
789793
790- assert.Equal(t, http.StatusInternalServerError, rec.Code)
791- assert.Contains(t, rec.Body.String(), "route removed but runtime cleanup failed")
794+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
795+ assert.Contains(t, string(body), "route removed but runtime cleanup failed")
792796 }
793797
794798 func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testing.T) {
@@ -820,16 +824,17 @@ func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testin
820824 d.ContainerSvc = containerSvc
821825 d.SecretSvc = secretSvc
822826 })
827+ server := newScopedTestServer(t, handler, "admin:routes:write")
823828
824- req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
825- req = req.WithContext(ctxWithScopes("admin:routes:write"))
826- rec := httptest.NewRecorder()
827-
828- handler.ServeHTTP(rec, req)
829+ req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil)
830+ require.NoError(t, err)
831+ respHTTP, err := http.DefaultClient.Do(req)
832+ require.NoError(t, err)
833+ defer respHTTP.Body.Close()
829834
830- require.Equal(t, http.StatusOK, rec.Code)
835+ require.Equal(t, http.StatusOK, respHTTP.StatusCode)
831836 var resp dto.RouteDeleteResponse
832- require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
837+ require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp))
833838 assert.Equal(t, "removed", resp.Status)
834839 require.NotNil(t, resp.Cleanup)
835840 assert.Equal(t, "app.example.com", resp.Cleanup.Domain)
@@ -720,8 +720,8 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
720 for _, tt := range tests {720 for _, tt := range tests {
721 t.Run(tt.name, func(t *testing.T) {721 t.Run(tt.name, func(t *testing.T) {
722 if tt.wantStatus == http.StatusOK {722 if tt.wantStatus == http.StatusOK {
723- configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()723+ configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once()
724- containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Maybe()724+ containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Once()
725 }725 }
726 726
727 req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)727 req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
@@ -751,16 +751,17 @@ func TestHandler_RoutesDelete_ReconcilesRuntimeWhenRouteAlreadyMissing(t *testin
751 d.ContainerSvc = containerSvc751 d.ContainerSvc = containerSvc
752 d.SecretSvc = secretSvc752 d.SecretSvc = secretSvc
753 })753 })
754+ server := newScopedTestServer(t, handler, "admin:routes:write")
754 755
755- req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)756+ req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil)
756- req = req.WithContext(ctxWithScopes("admin:routes:write"))757+ require.NoError(t, err)
757- rec := httptest.NewRecorder()758+ respHTTP, err := http.DefaultClient.Do(req)
758-759+ require.NoError(t, err)
759- handler.ServeHTTP(rec, req)760+ defer respHTTP.Body.Close()
760 761
761- require.Equal(t, http.StatusOK, rec.Code)762+ require.Equal(t, http.StatusOK, respHTTP.StatusCode)
762 var resp dto.RouteDeleteResponse763 var resp dto.RouteDeleteResponse
763- require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))764+ require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp))
764 require.NotNil(t, resp.Cleanup)765 require.NotNil(t, resp.Cleanup)
765 assert.Equal(t, "app.example.com", resp.Cleanup.Domain)766 assert.Equal(t, "app.example.com", resp.Cleanup.Domain)
766 }767 }
@@ -780,15 +781,18 @@ func TestHandler_RoutesDelete_ReturnsServerErrorWhenRuntimeCleanupFails(t *testi
780 d.ContainerSvc = containerSvc781 d.ContainerSvc = containerSvc
781 d.SecretSvc = secretSvc782 d.SecretSvc = secretSvc
782 })783 })
784+ server := newScopedTestServer(t, handler, "admin:routes:write")
783 785
784- req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)786+ req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil)
785- req = req.WithContext(ctxWithScopes("admin:routes:write"))787+ require.NoError(t, err)
786- rec := httptest.NewRecorder()788+ resp, err := http.DefaultClient.Do(req)
787-789+ require.NoError(t, err)
788- handler.ServeHTTP(rec, req)790+ defer resp.Body.Close()
791+ body, err := io.ReadAll(resp.Body)
792+ require.NoError(t, err)
789 793
790- assert.Equal(t, http.StatusInternalServerError, rec.Code)794+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
791- assert.Contains(t, rec.Body.String(), "route removed but runtime cleanup failed")795+ assert.Contains(t, string(body), "route removed but runtime cleanup failed")
792 }796 }
793 797
794 func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testing.T) {798 func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testing.T) {
@@ -820,16 +824,17 @@ func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testin
820 d.ContainerSvc = containerSvc824 d.ContainerSvc = containerSvc
821 d.SecretSvc = secretSvc825 d.SecretSvc = secretSvc
822 })826 })
827+ server := newScopedTestServer(t, handler, "admin:routes:write")
823 828
824- req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)829+ req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil)
825- req = req.WithContext(ctxWithScopes("admin:routes:write"))830+ require.NoError(t, err)
826- rec := httptest.NewRecorder()831+ respHTTP, err := http.DefaultClient.Do(req)
827-832+ require.NoError(t, err)
828- handler.ServeHTTP(rec, req)833+ defer respHTTP.Body.Close()
829 834
830- require.Equal(t, http.StatusOK, rec.Code)835+ require.Equal(t, http.StatusOK, respHTTP.StatusCode)
831 var resp dto.RouteDeleteResponse836 var resp dto.RouteDeleteResponse
832- require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))837+ require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp))
833 assert.Equal(t, "removed", resp.Status)838 assert.Equal(t, "removed", resp.Status)
834 require.NotNil(t, resp.Cleanup)839 require.NotNil(t, resp.Cleanup)
835 assert.Equal(t, "app.example.com", resp.Cleanup.Domain)840 assert.Equal(t, "app.example.com", resp.Cleanup.Domain)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close