detached 9111a36

refactor(cli): add local control plane and parity baseline

Unify management command execution behind local/remote control-plane adapters so local mode no longer depends on admin HTTP endpoints. Rename backup to backups (keeping backup as alias) and align CLI docs with local-first behavior.
commit
9111a36be4fe7d17b871e27bfa017ea29cb95479
author
brice · · 2026-02-09 05:41 UTC
parent
326215c
signature
unsigned
20 files · +1043 -81
M docs/cli/backup.md +13 -10
@@ -2,10 +2,10 @@
22
33 Manage backups for database attachments.
44
5-## gordon backup
5+## gordon backups
66
77 ```bash
8-gordon backup <subcommand>
8+gordon backups <subcommand>
99 ```
1010
1111 Subcommands:
@@ -15,9 +15,12 @@ Subcommands:
1515 - `detect <domain>` - Detect supported databases for a domain
1616 - `status` - Show backup status across domains
1717
18-> **Note:** Backup commands currently require remote mode (`--remote` + `--token`, or configured remotes).
18+> Backup commands work both locally and remotely.
1919 >
20-> `gordon backup run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`.
20+> - Local mode (no `--remote`) executes backups through in-process services.
21+> - Remote mode uses the admin API on the target instance.
22+>
23+> `gordon backups run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`.
2124
2225 In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags.
2326
@@ -25,22 +28,22 @@ In normal usage, configure remotes once (`gordon remotes ...`) and run backup co
2528
2629 ```bash
2730 # List all backups
28-gordon backup list
31+gordon backups list
2932
3033 # List backups for one domain
31-gordon backup list app.example.com
34+gordon backups list app.example.com
3235
3336 # Trigger backup (auto-detect when exactly one DB attachment exists)
34-gordon backup run app.example.com
37+gordon backups run app.example.com
3538
3639 # Trigger backup for specific attachment name
37-gordon backup run app.example.com --db postgres
40+gordon backups run app.example.com --db postgres
3841
3942 # Detect DB attachments
40-gordon backup detect app.example.com
43+gordon backups detect app.example.com
4144
4245 # Status summary
43-gordon backup status
46+gordon backups status
4447 ```
4548
4649 ## Output Columns
@@ -2,10 +2,10 @@
2 2
3 Manage backups for database attachments.3 Manage backups for database attachments.
4 4
5-## gordon backup5+## gordon backups
6 6
7 ```bash7 ```bash
8-gordon backup <subcommand>8+gordon backups <subcommand>
9 ```9 ```
10 10
11 Subcommands:11 Subcommands:
@@ -15,9 +15,12 @@ Subcommands:
15 - `detect <domain>` - Detect supported databases for a domain15 - `detect <domain>` - Detect supported databases for a domain
16 - `status` - Show backup status across domains16 - `status` - Show backup status across domains
17 17
18-> **Note:** Backup commands currently require remote mode (`--remote` + `--token`, or configured remotes).18+> Backup commands work both locally and remotely.
19 >19 >
20-> `gordon backup run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`.20+> - Local mode (no `--remote`) executes backups through in-process services.
21+> - Remote mode uses the admin API on the target instance.
22+>
23+> `gordon backups run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`.
21 24
22 In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags.25 In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags.
23 26
@@ -25,22 +28,22 @@ In normal usage, configure remotes once (`gordon remotes ...`) and run backup co
25 28
26 ```bash29 ```bash
27 # List all backups30 # List all backups
28-gordon backup list31+gordon backups list
29 32
30 # List backups for one domain33 # List backups for one domain
31-gordon backup list app.example.com34+gordon backups list app.example.com
32 35
33 # Trigger backup (auto-detect when exactly one DB attachment exists)36 # Trigger backup (auto-detect when exactly one DB attachment exists)
34-gordon backup run app.example.com37+gordon backups run app.example.com
35 38
36 # Trigger backup for specific attachment name39 # Trigger backup for specific attachment name
37-gordon backup run app.example.com --db postgres40+gordon backups run app.example.com --db postgres
38 41
39 # Detect DB attachments42 # Detect DB attachments
40-gordon backup detect app.example.com43+gordon backups detect app.example.com
41 44
42 # Status summary45 # Status summary
43-gordon backup status46+gordon backups status
44 ```47 ```
45 48
46 ## Output Columns49 ## Output Columns
A docs/cli/command-parity-matrix.md +19 -0
@@ -0,0 +1,19 @@
1+# CLI Command Parity Matrix
2+
3+This matrix tracks parity between remote mode (`--remote`) and local mode (no `--remote`).
4+
5+## Current Gaps
6+
7+| Command | Remote | Local | Gap | Owner Files |
8+|---|---|---|---|---|
9+| `gordon push` | Supported | Not supported | Requires remote mode | `internal/adapters/in/cli/push.go` |
10+| `gordon rollback` | Supported | Not supported | Requires remote mode | `internal/adapters/in/cli/rollback.go` |
11+| `gordon backups list/run/detect/status` | Supported | Not supported | Requires remote mode | `internal/adapters/in/cli/backup.go` |
12+| `gordon status` | Supported | Not supported | Requires remote mode | `internal/adapters/in/cli/routes.go` |
13+| `gordon restart --with-attachments` | Supported | Not supported | Local path blocks flag | `internal/adapters/in/cli/restart.go` |
14+
15+## Target
16+
17+- All management commands support local mode through in-process service calls.
18+- Remote mode remains supported through the admin HTTP API.
19+- Local mode does not proxy through `/admin/*` HTTP endpoints.
@@ -0,0 +1,19 @@
1+# CLI Command Parity Matrix
2+
3+This matrix tracks parity between remote mode (`--remote`) and local mode (no `--remote`).
4+
5+## Current Gaps
6+
7+| Command | Remote | Local | Gap | Owner Files |
8+|---|---|---|---|---|
9+| `gordon push` | Supported | Not supported | Requires remote mode | `internal/adapters/in/cli/push.go` |
10+| `gordon rollback` | Supported | Not supported | Requires remote mode | `internal/adapters/in/cli/rollback.go` |
11+| `gordon backups list/run/detect/status` | Supported | Not supported | Requires remote mode | `internal/adapters/in/cli/backup.go` |
12+| `gordon status` | Supported | Not supported | Requires remote mode | `internal/adapters/in/cli/routes.go` |
13+| `gordon restart --with-attachments` | Supported | Not supported | Local path blocks flag | `internal/adapters/in/cli/restart.go` |
14+
15+## Target
16+
17+- All management commands support local mode through in-process service calls.
18+- Remote mode remains supported through the admin HTTP API.
19+- Local mode does not proxy through `/admin/*` HTTP endpoints.
M docs/cli/index.md +7 -5
@@ -17,6 +17,8 @@ Commands are organized by where they run:
1717
1818 ## Management Commands (local or remote)
1919
20+Management commands run locally through in-process services by default. Add `--remote` to target another Gordon instance.
21+
2022 | Command | Description | Documentation |
2123 |---------|-------------|---------------|
2224 | `gordon routes` | Manage routes | [routes](./routes.md) |
@@ -29,7 +31,7 @@ Commands are organized by where they run:
2931 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
3032 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
3133 | `gordon status` | Show Gordon server status | [status](./status.md) |
32-| `gordon backup` | Manage database backups | [backup](./backup.md) |
34+| `gordon backups` | Manage database backups | [backup](./backup.md) |
3335
3436 ## Client Commands
3537
@@ -75,10 +77,10 @@ gordon logs myapp.local -f # Follow container logs
7577 gordon version
7678
7779 # Backups
78-gordon backup list
79-gordon backup run app.example.com
80-gordon backup detect app.example.com
81-gordon backup status
80+gordon backups list
81+gordon backups run app.example.com
82+gordon backups detect app.example.com
83+gordon backups status
8284
8385 # Authentication
8486 gordon auth token generate --subject ci-bot --expiry 0
@@ -17,6 +17,8 @@ Commands are organized by where they run:
17 17
18 ## Management Commands (local or remote)18 ## Management Commands (local or remote)
19 19
20+Management commands run locally through in-process services by default. Add `--remote` to target another Gordon instance.
21+
20 | Command | Description | Documentation |22 | Command | Description | Documentation |
21 |---------|-------------|---------------|23 |---------|-------------|---------------|
22 | `gordon routes` | Manage routes | [routes](./routes.md) |24 | `gordon routes` | Manage routes | [routes](./routes.md) |
@@ -29,7 +31,7 @@ Commands are organized by where they run:
29 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |31 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
30 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |32 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
31 | `gordon status` | Show Gordon server status | [status](./status.md) |33 | `gordon status` | Show Gordon server status | [status](./status.md) |
32-| `gordon backup` | Manage database backups | [backup](./backup.md) |34+| `gordon backups` | Manage database backups | [backup](./backup.md) |
33 35
34 ## Client Commands36 ## Client Commands
35 37
@@ -75,10 +77,10 @@ gordon logs myapp.local -f # Follow container logs
75 gordon version77 gordon version
76 78
77 # Backups79 # Backups
78-gordon backup list80+gordon backups list
79-gordon backup run app.example.com81+gordon backups run app.example.com
80-gordon backup detect app.example.com82+gordon backups detect app.example.com
81-gordon backup status83+gordon backups status
82 84
83 # Authentication85 # Authentication
84 gordon auth token generate --subject ci-bot --expiry 086 gordon auth token generate --subject ci-bot --expiry 0
M docs/cli/status.md +11 -4
@@ -7,10 +7,14 @@ Show Gordon server status and container health.
77 Display server configuration and container status for all routes.
88
99 ```bash
10+gordon status
1011 gordon status --remote https://gordon.mydomain.com --token $TOKEN
1112 ```
1213
13-> **Note:** The status command requires remote mode. See [CLI Overview](./index.md) for targeting options.
14+`gordon status` works in local mode and remote mode.
15+
16+- Local mode reads status from in-process services.
17+- Remote mode reads status from the target admin API.
1418
1519 ### Output
1620
@@ -70,9 +74,12 @@ The status command uses global flags for remote access:
7074
7175 ## Examples
7276
73-### Check Remote Server Status
77+### Check Local or Remote Status
7478
7579 ```bash
80+# Local
81+gordon status
82+
7683 # Using flags
7784 gordon status --remote https://gordon.mydomain.com --token $TOKEN
7885
@@ -89,9 +96,9 @@ gordon status
8996 gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)"
9097 ```
9198
92-## Required Permissions
99+## Required Permissions (Remote Only)
93100
94-The status endpoint requires `admin:status:read` scope in the authentication token.
101+Remote status calls require `admin:status:read` scope in the authentication token.
95102
96103 ```bash
97104 # Generate token with required scope
@@ -7,10 +7,14 @@ Show Gordon server status and container health.
7 Display server configuration and container status for all routes.7 Display server configuration and container status for all routes.
8 8
9 ```bash9 ```bash
10+gordon status
10 gordon status --remote https://gordon.mydomain.com --token $TOKEN11 gordon status --remote https://gordon.mydomain.com --token $TOKEN
11 ```12 ```
12 13
13-> **Note:** The status command requires remote mode. See [CLI Overview](./index.md) for targeting options.14+`gordon status` works in local mode and remote mode.
15+
16+- Local mode reads status from in-process services.
17+- Remote mode reads status from the target admin API.
14 18
15 ### Output19 ### Output
16 20
@@ -70,9 +74,12 @@ The status command uses global flags for remote access:
70 74
71 ## Examples75 ## Examples
72 76
73-### Check Remote Server Status77+### Check Local or Remote Status
74 78
75 ```bash79 ```bash
80+# Local
81+gordon status
82+
76 # Using flags83 # Using flags
77 gordon status --remote https://gordon.mydomain.com --token $TOKEN84 gordon status --remote https://gordon.mydomain.com --token $TOKEN
78 85
@@ -89,9 +96,9 @@ gordon status
89 gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)"96 gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)"
90 ```97 ```
91 98
92-## Required Permissions99+## Required Permissions (Remote Only)
93 100
94-The status endpoint requires `admin:status:read` scope in the authentication token.101+Remote status calls require `admin:status:read` scope in the authentication token.
95 102
96 ```bash103 ```bash
97 # Generate token with required scope104 # Generate token with required scope
M docs/config/backups.md +1 -1
@@ -6,7 +6,7 @@ Configure automatic PostgreSQL logical backups for attachment containers.
66
77 When backups are enabled, Gordon detects PostgreSQL attachments for each route and can:
88
9-- Run on-demand backups via `gordon backup run`
9+- Run on-demand backups via `gordon backups run`
1010 - Store backup artifacts on the Gordon host filesystem
1111 - Apply retention policies per schedule tier
1212
@@ -6,7 +6,7 @@ Configure automatic PostgreSQL logical backups for attachment containers.
6 6
7 When backups are enabled, Gordon detects PostgreSQL attachments for each route and can:7 When backups are enabled, Gordon detects PostgreSQL attachments for each route and can:
8 8
9-- Run on-demand backups via `gordon backup run`9+- Run on-demand backups via `gordon backups run`
10 - Store backup artifacts on the Gordon host filesystem10 - Store backup artifacts on the Gordon host filesystem
11 - Apply retention policies per schedule tier11 - Apply retention policies per schedule tier
12 12
M internal/adapters/in/cli/backup.go +26 -20
@@ -12,11 +12,13 @@ import (
1212 // newBackupCmd creates the backup command group.
1313 func newBackupCmd() *cobra.Command {
1414 cmd := &cobra.Command{
15- Use: "backup",
16- Short: "Manage database backups",
17- Long: `Manage database backups through Gordon's admin API.
15+ Use: "backups",
16+ Aliases: []string{"backup"},
17+ Short: "Manage database backups",
18+ Long: `Manage database backups.
1819
19-These commands currently require remote mode with a configured target.`,
20+Runs locally via in-process services by default, or against a remote Gordon
21+instance when --remote targeting is configured.`,
2022 }
2123
2224 cmd.AddCommand(newBackupListCmd())
@@ -33,17 +35,18 @@ func newBackupListCmd() *cobra.Command {
3335 Short: "List backups",
3436 Args: cobra.MaximumNArgs(1),
3537 RunE: func(cmd *cobra.Command, args []string) error {
36- client, isRemote := GetRemoteClient()
37- if !isRemote {
38- return fmt.Errorf("backup commands require a configured remote target")
38+ handle, err := resolveControlPlane(configPath)
39+ if err != nil {
40+ return err
3941 }
42+ defer handle.close()
4043
4144 domainName := ""
4245 if len(args) == 1 {
4346 domainName = args[0]
4447 }
4548
46- jobs, err := client.ListBackups(cmd.Context(), domainName)
49+ jobs, err := handle.plane.ListBackups(cmd.Context(), domainName)
4750 if err != nil {
4851 return fmt.Errorf("failed to list backups: %w", err)
4952 }
@@ -79,12 +82,13 @@ func newBackupRunCmd() *cobra.Command {
7982 Short: "Run backup now",
8083 Args: cobra.ExactArgs(1),
8184 RunE: func(cmd *cobra.Command, args []string) error {
82- client, isRemote := GetRemoteClient()
83- if !isRemote {
84- return fmt.Errorf("backup commands require a configured remote target")
85+ handle, err := resolveControlPlane(configPath)
86+ if err != nil {
87+ return err
8588 }
89+ defer handle.close()
8690
87- result, err := client.RunBackup(cmd.Context(), args[0], dbName)
91+ result, err := handle.plane.RunBackup(cmd.Context(), args[0], dbName)
8892 if err != nil {
8993 return fmt.Errorf("failed to run backup: %w", err)
9094 }
@@ -117,12 +121,13 @@ func newBackupDetectCmd() *cobra.Command {
117121 Short: "Detect databases for domain",
118122 Args: cobra.ExactArgs(1),
119123 RunE: func(cmd *cobra.Command, args []string) error {
120- client, isRemote := GetRemoteClient()
121- if !isRemote {
122- return fmt.Errorf("backup commands require a configured remote target")
124+ handle, err := resolveControlPlane(configPath)
125+ if err != nil {
126+ return err
123127 }
128+ defer handle.close()
124129
125- dbs, err := client.DetectDatabases(cmd.Context(), args[0])
130+ dbs, err := handle.plane.DetectDatabases(cmd.Context(), args[0])
126131 if err != nil {
127132 return fmt.Errorf("failed to detect databases: %w", err)
128133 }
@@ -155,12 +160,13 @@ func newBackupStatusCmd() *cobra.Command {
155160 Use: "status",
156161 Short: "Show backup status",
157162 RunE: func(cmd *cobra.Command, args []string) error {
158- client, isRemote := GetRemoteClient()
159- if !isRemote {
160- return fmt.Errorf("backup commands require a configured remote target")
163+ handle, err := resolveControlPlane(configPath)
164+ if err != nil {
165+ return err
161166 }
167+ defer handle.close()
162168
163- jobs, err := client.BackupStatus(cmd.Context())
169+ jobs, err := handle.plane.BackupStatus(cmd.Context())
164170 if err != nil {
165171 return fmt.Errorf("failed to get backup status: %w", err)
166172 }
@@ -12,11 +12,13 @@ import (
12 // newBackupCmd creates the backup command group.12 // newBackupCmd creates the backup command group.
13 func newBackupCmd() *cobra.Command {13 func newBackupCmd() *cobra.Command {
14 cmd := &cobra.Command{14 cmd := &cobra.Command{
15- Use: "backup",15+ Use: "backups",
16- Short: "Manage database backups",16+ Aliases: []string{"backup"},
17- Long: `Manage database backups through Gordon's admin API.17+ Short: "Manage database backups",
18+ Long: `Manage database backups.
18 19
19-These commands currently require remote mode with a configured target.`,20+Runs locally via in-process services by default, or against a remote Gordon
21+instance when --remote targeting is configured.`,
20 }22 }
21 23
22 cmd.AddCommand(newBackupListCmd())24 cmd.AddCommand(newBackupListCmd())
@@ -33,17 +35,18 @@ func newBackupListCmd() *cobra.Command {
33 Short: "List backups",35 Short: "List backups",
34 Args: cobra.MaximumNArgs(1),36 Args: cobra.MaximumNArgs(1),
35 RunE: func(cmd *cobra.Command, args []string) error {37 RunE: func(cmd *cobra.Command, args []string) error {
36- client, isRemote := GetRemoteClient()38+ handle, err := resolveControlPlane(configPath)
37- if !isRemote {39+ if err != nil {
38- return fmt.Errorf("backup commands require a configured remote target")40+ return err
39 }41 }
42+ defer handle.close()
40 43
41 domainName := ""44 domainName := ""
42 if len(args) == 1 {45 if len(args) == 1 {
43 domainName = args[0]46 domainName = args[0]
44 }47 }
45 48
46- jobs, err := client.ListBackups(cmd.Context(), domainName)49+ jobs, err := handle.plane.ListBackups(cmd.Context(), domainName)
47 if err != nil {50 if err != nil {
48 return fmt.Errorf("failed to list backups: %w", err)51 return fmt.Errorf("failed to list backups: %w", err)
49 }52 }
@@ -79,12 +82,13 @@ func newBackupRunCmd() *cobra.Command {
79 Short: "Run backup now",82 Short: "Run backup now",
80 Args: cobra.ExactArgs(1),83 Args: cobra.ExactArgs(1),
81 RunE: func(cmd *cobra.Command, args []string) error {84 RunE: func(cmd *cobra.Command, args []string) error {
82- client, isRemote := GetRemoteClient()85+ handle, err := resolveControlPlane(configPath)
83- if !isRemote {86+ if err != nil {
84- return fmt.Errorf("backup commands require a configured remote target")87+ return err
85 }88 }
89+ defer handle.close()
86 90
87- result, err := client.RunBackup(cmd.Context(), args[0], dbName)91+ result, err := handle.plane.RunBackup(cmd.Context(), args[0], dbName)
88 if err != nil {92 if err != nil {
89 return fmt.Errorf("failed to run backup: %w", err)93 return fmt.Errorf("failed to run backup: %w", err)
90 }94 }
@@ -117,12 +121,13 @@ func newBackupDetectCmd() *cobra.Command {
117 Short: "Detect databases for domain",121 Short: "Detect databases for domain",
118 Args: cobra.ExactArgs(1),122 Args: cobra.ExactArgs(1),
119 RunE: func(cmd *cobra.Command, args []string) error {123 RunE: func(cmd *cobra.Command, args []string) error {
120- client, isRemote := GetRemoteClient()124+ handle, err := resolveControlPlane(configPath)
121- if !isRemote {125+ if err != nil {
122- return fmt.Errorf("backup commands require a configured remote target")126+ return err
123 }127 }
128+ defer handle.close()
124 129
125- dbs, err := client.DetectDatabases(cmd.Context(), args[0])130+ dbs, err := handle.plane.DetectDatabases(cmd.Context(), args[0])
126 if err != nil {131 if err != nil {
127 return fmt.Errorf("failed to detect databases: %w", err)132 return fmt.Errorf("failed to detect databases: %w", err)
128 }133 }
@@ -155,12 +160,13 @@ func newBackupStatusCmd() *cobra.Command {
155 Use: "status",160 Use: "status",
156 Short: "Show backup status",161 Short: "Show backup status",
157 RunE: func(cmd *cobra.Command, args []string) error {162 RunE: func(cmd *cobra.Command, args []string) error {
158- client, isRemote := GetRemoteClient()163+ handle, err := resolveControlPlane(configPath)
159- if !isRemote {164+ if err != nil {
160- return fmt.Errorf("backup commands require a configured remote target")165+ return err
161 }166 }
167+ defer handle.close()
162 168
163- jobs, err := client.BackupStatus(cmd.Context())169+ jobs, err := handle.plane.BackupStatus(cmd.Context())
164 if err != nil {170 if err != nil {
165 return fmt.Errorf("failed to get backup status: %w", err)171 return fmt.Errorf("failed to get backup status: %w", err)
166 }172 }
A internal/adapters/in/cli/controlplane.go +49 -0
@@ -0,0 +1,49 @@
1+package cli
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/adapters/dto"
7+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// ControlPlane defines command operations available to CLI execution paths.
12+//
13+// Remote implementations call admin HTTP APIs.
14+// Local implementations call services directly in-process.
15+type ControlPlane interface {
16+ ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error)
17+ GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error)
18+ GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error)
19+ AddRoute(ctx context.Context, route domain.Route) error
20+ UpdateRoute(ctx context.Context, route domain.Route) error
21+ RemoveRoute(ctx context.Context, routeDomain string) error
22+
23+ ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)
24+ SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error
25+ DeleteSecret(ctx context.Context, secretDomain, key string) error
26+ SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error
27+ DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error
28+
29+ GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error)
30+ GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error)
31+ AddAttachment(ctx context.Context, domainOrGroup, image string) error
32+ RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
33+
34+ GetStatus(ctx context.Context) (*remote.Status, error)
35+ Reload(ctx context.Context) error
36+ Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
37+ Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)
38+ ListTags(ctx context.Context, repository string) ([]string, error)
39+
40+ ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error)
41+ BackupStatus(ctx context.Context) ([]dto.BackupJob, error)
42+ RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)
43+ DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)
44+
45+ GetProcessLogs(ctx context.Context, lines int) ([]string, error)
46+ GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
47+ StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)
48+ StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error)
49+}
@@ -0,0 +1,49 @@
1+package cli
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/adapters/dto"
7+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// ControlPlane defines command operations available to CLI execution paths.
12+//
13+// Remote implementations call admin HTTP APIs.
14+// Local implementations call services directly in-process.
15+type ControlPlane interface {
16+ ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error)
17+ GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error)
18+ GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error)
19+ AddRoute(ctx context.Context, route domain.Route) error
20+ UpdateRoute(ctx context.Context, route domain.Route) error
21+ RemoveRoute(ctx context.Context, routeDomain string) error
22+
23+ ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)
24+ SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error
25+ DeleteSecret(ctx context.Context, secretDomain, key string) error
26+ SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error
27+ DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error
28+
29+ GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error)
30+ GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error)
31+ AddAttachment(ctx context.Context, domainOrGroup, image string) error
32+ RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
33+
34+ GetStatus(ctx context.Context) (*remote.Status, error)
35+ Reload(ctx context.Context) error
36+ Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
37+ Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)
38+ ListTags(ctx context.Context, repository string) ([]string, error)
39+
40+ ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error)
41+ BackupStatus(ctx context.Context) ([]dto.BackupJob, error)
42+ RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)
43+ DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)
44+
45+ GetProcessLogs(ctx context.Context, lines int) ([]string, error)
46+ GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
47+ StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)
48+ StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error)
49+}
A internal/adapters/in/cli/controlplane_local.go +418 -0
@@ -0,0 +1,418 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/adapters/dto"
9+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10+ "github.com/bnema/gordon/internal/app"
11+ "github.com/bnema/gordon/internal/boundaries/in"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+type localControlPlane struct {
16+ configSvc in.ConfigService
17+ secretSvc in.SecretService
18+ containerSvc in.ContainerService
19+ backupSvc in.BackupService
20+ registrySvc in.RegistryService
21+ healthSvc in.HealthService
22+ logSvc in.LogService
23+}
24+
25+func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
26+ if kernel == nil {
27+ return &localControlPlane{}
28+ }
29+
30+ return &localControlPlane{
31+ configSvc: kernel.Config(),
32+ secretSvc: kernel.Secrets(),
33+ containerSvc: kernel.Container(),
34+ backupSvc: kernel.Backup(),
35+ registrySvc: kernel.Registry(),
36+ healthSvc: kernel.Health(),
37+ logSvc: kernel.Logs(),
38+ }
39+}
40+
41+func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
42+ if l.containerSvc != nil {
43+ return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil
44+ }
45+
46+ if l.configSvc == nil {
47+ return nil, fmt.Errorf("local config service unavailable")
48+ }
49+
50+ routes := l.configSvc.GetRoutes(ctx)
51+ infos := make([]remote.RouteInfo, 0, len(routes))
52+ for _, route := range routes {
53+ infos = append(infos, remote.RouteInfo{Domain: route.Domain, Image: route.Image})
54+ }
55+ return infos, nil
56+}
57+
58+func (l *localControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
59+ if l.healthSvc == nil {
60+ return map[string]*remote.RouteHealth{}, nil
61+ }
62+
63+ health := l.healthSvc.CheckAllRoutes(ctx)
64+ result := make(map[string]*remote.RouteHealth, len(health))
65+ for domainName, h := range health {
66+ if h == nil {
67+ continue
68+ }
69+ result[domainName] = &remote.RouteHealth{
70+ ContainerStatus: h.ContainerStatus,
71+ HTTPStatus: h.HTTPStatus,
72+ ResponseTimeMs: h.ResponseTimeMs,
73+ Healthy: h.Healthy,
74+ Error: h.Error,
75+ }
76+ }
77+ return result, nil
78+}
79+
80+func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
81+ if l.configSvc == nil {
82+ return nil, fmt.Errorf("local config service unavailable")
83+ }
84+ return l.configSvc.GetRoute(ctx, routeDomain)
85+}
86+
87+func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
88+ if l.configSvc == nil {
89+ return fmt.Errorf("local config service unavailable")
90+ }
91+ return l.configSvc.AddRoute(ctx, route)
92+}
93+
94+func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
95+ if l.configSvc == nil {
96+ return fmt.Errorf("local config service unavailable")
97+ }
98+ return l.configSvc.UpdateRoute(ctx, route)
99+}
100+
101+func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
102+ if l.configSvc == nil {
103+ return fmt.Errorf("local config service unavailable")
104+ }
105+ return l.configSvc.RemoveRoute(ctx, routeDomain)
106+}
107+
108+func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
109+ if l.secretSvc == nil {
110+ return nil, fmt.Errorf("local secret service unavailable")
111+ }
112+
113+ keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
114+ if err != nil {
115+ return nil, err
116+ }
117+
118+ result := &remote.SecretsListResult{
119+ Domain: secretDomain,
120+ Keys: keys,
121+ }
122+ for _, att := range attachments {
123+ result.Attachments = append(result.Attachments, remote.AttachmentSecrets{
124+ Service: att.Service,
125+ Keys: att.Keys,
126+ })
127+ }
128+
129+ return result, nil
130+}
131+
132+func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
133+ if l.secretSvc == nil {
134+ return fmt.Errorf("local secret service unavailable")
135+ }
136+ return l.secretSvc.Set(ctx, secretDomain, secrets)
137+}
138+
139+func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
140+ if l.secretSvc == nil {
141+ return fmt.Errorf("local secret service unavailable")
142+ }
143+ return l.secretSvc.Delete(ctx, secretDomain, key)
144+}
145+
146+func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
147+ if l.secretSvc == nil {
148+ return fmt.Errorf("local secret service unavailable")
149+ }
150+ return l.secretSvc.SetAttachment(ctx, domainName, service, secrets)
151+}
152+
153+func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
154+ if l.secretSvc == nil {
155+ return fmt.Errorf("local secret service unavailable")
156+ }
157+ return l.secretSvc.DeleteAttachment(ctx, domainName, service, key)
158+}
159+
160+func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
161+ if l.configSvc == nil {
162+ return nil, fmt.Errorf("local config service unavailable")
163+ }
164+ return l.configSvc.GetAllAttachments(ctx), nil
165+}
166+
167+func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
168+ if l.configSvc == nil {
169+ return nil, fmt.Errorf("local config service unavailable")
170+ }
171+ return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup)
172+}
173+
174+func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
175+ if l.configSvc == nil {
176+ return fmt.Errorf("local config service unavailable")
177+ }
178+ return l.configSvc.AddAttachment(ctx, domainOrGroup, image)
179+}
180+
181+func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
182+ if l.configSvc == nil {
183+ return fmt.Errorf("local config service unavailable")
184+ }
185+ return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)
186+}
187+
188+func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
189+ if l.configSvc == nil {
190+ return nil, fmt.Errorf("local config service unavailable")
191+ }
192+
193+ status := &remote.Status{
194+ Routes: len(l.configSvc.GetRoutes(ctx)),
195+ RegistryDomain: l.configSvc.GetRegistryDomain(),
196+ RegistryPort: l.configSvc.GetRegistryPort(),
197+ ServerPort: l.configSvc.GetServerPort(),
198+ AutoRoute: l.configSvc.IsAutoRouteEnabled(),
199+ NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(),
200+ ContainerStatus: map[string]string{},
201+ }
202+
203+ if l.containerSvc != nil {
204+ for domainName, container := range l.containerSvc.List(ctx) {
205+ if container == nil {
206+ continue
207+ }
208+ status.ContainerStatus[domainName] = container.Status
209+ }
210+ }
211+
212+ return status, nil
213+}
214+
215+func (l *localControlPlane) Reload(_ context.Context) error {
216+ return app.SendReloadSignal()
217+}
218+
219+func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
220+ if l.containerSvc != nil && l.configSvc != nil {
221+ route, err := l.configSvc.GetRoute(ctx, deployDomain)
222+ if err != nil {
223+ return nil, err
224+ }
225+ container, err := l.containerSvc.Deploy(ctx, *route)
226+ if err != nil {
227+ return nil, err
228+ }
229+ result := &remote.DeployResult{Status: "deployed", Domain: deployDomain}
230+ if container != nil {
231+ result.ContainerID = container.ID
232+ }
233+ return result, nil
234+ }
235+
236+ domainName, err := app.SendDeploySignal(deployDomain)
237+ if err != nil {
238+ return nil, err
239+ }
240+ return &remote.DeployResult{Status: "queued", Domain: domainName}, nil
241+}
242+
243+func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
244+ if l.containerSvc == nil {
245+ if withAttachments {
246+ return nil, fmt.Errorf("local restart with attachments requires active local container service")
247+ }
248+ domainName, err := app.SendDeploySignal(restartDomain)
249+ if err != nil {
250+ return nil, err
251+ }
252+ return &remote.RestartResult{Status: "queued", Domain: domainName}, nil
253+ }
254+
255+ if err := l.containerSvc.SyncContainers(ctx); err != nil {
256+ return nil, err
257+ }
258+ if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
259+ return nil, err
260+ }
261+
262+ return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil
263+}
264+
265+func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
266+ if l.registrySvc == nil {
267+ return nil, fmt.Errorf("local registry service unavailable")
268+ }
269+ return l.registrySvc.ListTags(ctx, repository)
270+}
271+
272+func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
273+ if l.backupSvc == nil {
274+ return nil, fmt.Errorf("local backup service unavailable")
275+ }
276+ jobs, err := l.backupSvc.ListBackups(ctx, backupDomain)
277+ if err != nil {
278+ return nil, err
279+ }
280+ return toDTOBackupJobs(jobs), nil
281+}
282+
283+func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
284+ if l.backupSvc == nil {
285+ return nil, fmt.Errorf("local backup service unavailable")
286+ }
287+ jobs, err := l.backupSvc.Status(ctx)
288+ if err != nil {
289+ return nil, err
290+ }
291+ return toDTOBackupJobs(jobs), nil
292+}
293+
294+func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
295+ if l.backupSvc == nil {
296+ return nil, fmt.Errorf("local backup service unavailable")
297+ }
298+ result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName)
299+ if err != nil {
300+ return nil, err
301+ }
302+ if result == nil {
303+ return &dto.BackupRunResponse{Status: "ok"}, nil
304+ }
305+ job := toDTOBackupJob(result.Job)
306+ return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil
307+}
308+
309+func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
310+ if l.backupSvc == nil {
311+ return nil, fmt.Errorf("local backup service unavailable")
312+ }
313+ dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain)
314+ if err != nil {
315+ return nil, err
316+ }
317+ out := make([]dto.DatabaseInfo, 0, len(dbs))
318+ for _, db := range dbs {
319+ out = append(out, dto.DatabaseInfo{
320+ Type: string(db.Type),
321+ Name: db.Name,
322+ Version: db.Version,
323+ Host: db.Host,
324+ Port: db.Port,
325+ ContainerID: db.ContainerID,
326+ ImageName: db.ImageName,
327+ })
328+ }
329+ return out, nil
330+}
331+
332+func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
333+ if l.logSvc == nil {
334+ return nil, fmt.Errorf("local log service unavailable")
335+ }
336+ return l.logSvc.GetProcessLogs(ctx, lines)
337+}
338+
339+func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
340+ if l.logSvc == nil {
341+ return nil, fmt.Errorf("local log service unavailable")
342+ }
343+ return l.logSvc.GetContainerLogs(ctx, logDomain, lines)
344+}
345+
346+func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
347+ if l.logSvc == nil {
348+ return nil, fmt.Errorf("local log service unavailable")
349+ }
350+ return l.logSvc.FollowProcessLogs(ctx, lines)
351+}
352+
353+func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
354+ if l.logSvc == nil {
355+ return nil, fmt.Errorf("local log service unavailable")
356+ }
357+ return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)
358+}
359+
360+func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
361+ out := make([]remote.RouteInfo, 0, len(routes))
362+ for _, route := range routes {
363+ attachments := make([]dto.Attachment, 0, len(route.Attachments))
364+ for _, attachment := range route.Attachments {
365+ attachments = append(attachments, dto.Attachment{
366+ Name: attachment.Name,
367+ Image: attachment.Image,
368+ ContainerID: attachment.ContainerID,
369+ Status: attachment.Status,
370+ Network: attachment.Network,
371+ })
372+ }
373+
374+ out = append(out, remote.RouteInfo{
375+ Domain: route.Domain,
376+ Image: route.Image,
377+ ContainerID: route.ContainerID,
378+ ContainerStatus: route.ContainerStatus,
379+ Network: route.Network,
380+ Attachments: attachments,
381+ })
382+ }
383+ return out
384+}
385+
386+func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
387+ out := make([]dto.BackupJob, 0, len(jobs))
388+ for _, job := range jobs {
389+ out = append(out, toDTOBackupJob(job))
390+ }
391+ return out
392+}
393+
394+func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
395+ var startedAt *time.Time
396+ if !job.StartedAt.IsZero() {
397+ t := job.StartedAt
398+ startedAt = &t
399+ }
400+ var completedAt *time.Time
401+ if !job.CompletedAt.IsZero() {
402+ t := job.CompletedAt
403+ completedAt = &t
404+ }
405+
406+ return dto.BackupJob{
407+ ID: job.ID,
408+ Domain: job.Domain,
409+ DBName: job.DBName,
410+ Schedule: string(job.Schedule),
411+ Type: string(job.Type),
412+ Status: string(job.Status),
413+ StartedAt: startedAt,
414+ CompletedAt: completedAt,
415+ SizeBytes: job.SizeBytes,
416+ Error: job.Error,
417+ }
418+}
@@ -0,0 +1,418 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/adapters/dto"
9+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10+ "github.com/bnema/gordon/internal/app"
11+ "github.com/bnema/gordon/internal/boundaries/in"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+type localControlPlane struct {
16+ configSvc in.ConfigService
17+ secretSvc in.SecretService
18+ containerSvc in.ContainerService
19+ backupSvc in.BackupService
20+ registrySvc in.RegistryService
21+ healthSvc in.HealthService
22+ logSvc in.LogService
23+}
24+
25+func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
26+ if kernel == nil {
27+ return &localControlPlane{}
28+ }
29+
30+ return &localControlPlane{
31+ configSvc: kernel.Config(),
32+ secretSvc: kernel.Secrets(),
33+ containerSvc: kernel.Container(),
34+ backupSvc: kernel.Backup(),
35+ registrySvc: kernel.Registry(),
36+ healthSvc: kernel.Health(),
37+ logSvc: kernel.Logs(),
38+ }
39+}
40+
41+func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
42+ if l.containerSvc != nil {
43+ return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil
44+ }
45+
46+ if l.configSvc == nil {
47+ return nil, fmt.Errorf("local config service unavailable")
48+ }
49+
50+ routes := l.configSvc.GetRoutes(ctx)
51+ infos := make([]remote.RouteInfo, 0, len(routes))
52+ for _, route := range routes {
53+ infos = append(infos, remote.RouteInfo{Domain: route.Domain, Image: route.Image})
54+ }
55+ return infos, nil
56+}
57+
58+func (l *localControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
59+ if l.healthSvc == nil {
60+ return map[string]*remote.RouteHealth{}, nil
61+ }
62+
63+ health := l.healthSvc.CheckAllRoutes(ctx)
64+ result := make(map[string]*remote.RouteHealth, len(health))
65+ for domainName, h := range health {
66+ if h == nil {
67+ continue
68+ }
69+ result[domainName] = &remote.RouteHealth{
70+ ContainerStatus: h.ContainerStatus,
71+ HTTPStatus: h.HTTPStatus,
72+ ResponseTimeMs: h.ResponseTimeMs,
73+ Healthy: h.Healthy,
74+ Error: h.Error,
75+ }
76+ }
77+ return result, nil
78+}
79+
80+func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
81+ if l.configSvc == nil {
82+ return nil, fmt.Errorf("local config service unavailable")
83+ }
84+ return l.configSvc.GetRoute(ctx, routeDomain)
85+}
86+
87+func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
88+ if l.configSvc == nil {
89+ return fmt.Errorf("local config service unavailable")
90+ }
91+ return l.configSvc.AddRoute(ctx, route)
92+}
93+
94+func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
95+ if l.configSvc == nil {
96+ return fmt.Errorf("local config service unavailable")
97+ }
98+ return l.configSvc.UpdateRoute(ctx, route)
99+}
100+
101+func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
102+ if l.configSvc == nil {
103+ return fmt.Errorf("local config service unavailable")
104+ }
105+ return l.configSvc.RemoveRoute(ctx, routeDomain)
106+}
107+
108+func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
109+ if l.secretSvc == nil {
110+ return nil, fmt.Errorf("local secret service unavailable")
111+ }
112+
113+ keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
114+ if err != nil {
115+ return nil, err
116+ }
117+
118+ result := &remote.SecretsListResult{
119+ Domain: secretDomain,
120+ Keys: keys,
121+ }
122+ for _, att := range attachments {
123+ result.Attachments = append(result.Attachments, remote.AttachmentSecrets{
124+ Service: att.Service,
125+ Keys: att.Keys,
126+ })
127+ }
128+
129+ return result, nil
130+}
131+
132+func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
133+ if l.secretSvc == nil {
134+ return fmt.Errorf("local secret service unavailable")
135+ }
136+ return l.secretSvc.Set(ctx, secretDomain, secrets)
137+}
138+
139+func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
140+ if l.secretSvc == nil {
141+ return fmt.Errorf("local secret service unavailable")
142+ }
143+ return l.secretSvc.Delete(ctx, secretDomain, key)
144+}
145+
146+func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
147+ if l.secretSvc == nil {
148+ return fmt.Errorf("local secret service unavailable")
149+ }
150+ return l.secretSvc.SetAttachment(ctx, domainName, service, secrets)
151+}
152+
153+func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
154+ if l.secretSvc == nil {
155+ return fmt.Errorf("local secret service unavailable")
156+ }
157+ return l.secretSvc.DeleteAttachment(ctx, domainName, service, key)
158+}
159+
160+func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
161+ if l.configSvc == nil {
162+ return nil, fmt.Errorf("local config service unavailable")
163+ }
164+ return l.configSvc.GetAllAttachments(ctx), nil
165+}
166+
167+func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
168+ if l.configSvc == nil {
169+ return nil, fmt.Errorf("local config service unavailable")
170+ }
171+ return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup)
172+}
173+
174+func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
175+ if l.configSvc == nil {
176+ return fmt.Errorf("local config service unavailable")
177+ }
178+ return l.configSvc.AddAttachment(ctx, domainOrGroup, image)
179+}
180+
181+func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
182+ if l.configSvc == nil {
183+ return fmt.Errorf("local config service unavailable")
184+ }
185+ return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)
186+}
187+
188+func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
189+ if l.configSvc == nil {
190+ return nil, fmt.Errorf("local config service unavailable")
191+ }
192+
193+ status := &remote.Status{
194+ Routes: len(l.configSvc.GetRoutes(ctx)),
195+ RegistryDomain: l.configSvc.GetRegistryDomain(),
196+ RegistryPort: l.configSvc.GetRegistryPort(),
197+ ServerPort: l.configSvc.GetServerPort(),
198+ AutoRoute: l.configSvc.IsAutoRouteEnabled(),
199+ NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(),
200+ ContainerStatus: map[string]string{},
201+ }
202+
203+ if l.containerSvc != nil {
204+ for domainName, container := range l.containerSvc.List(ctx) {
205+ if container == nil {
206+ continue
207+ }
208+ status.ContainerStatus[domainName] = container.Status
209+ }
210+ }
211+
212+ return status, nil
213+}
214+
215+func (l *localControlPlane) Reload(_ context.Context) error {
216+ return app.SendReloadSignal()
217+}
218+
219+func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
220+ if l.containerSvc != nil && l.configSvc != nil {
221+ route, err := l.configSvc.GetRoute(ctx, deployDomain)
222+ if err != nil {
223+ return nil, err
224+ }
225+ container, err := l.containerSvc.Deploy(ctx, *route)
226+ if err != nil {
227+ return nil, err
228+ }
229+ result := &remote.DeployResult{Status: "deployed", Domain: deployDomain}
230+ if container != nil {
231+ result.ContainerID = container.ID
232+ }
233+ return result, nil
234+ }
235+
236+ domainName, err := app.SendDeploySignal(deployDomain)
237+ if err != nil {
238+ return nil, err
239+ }
240+ return &remote.DeployResult{Status: "queued", Domain: domainName}, nil
241+}
242+
243+func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
244+ if l.containerSvc == nil {
245+ if withAttachments {
246+ return nil, fmt.Errorf("local restart with attachments requires active local container service")
247+ }
248+ domainName, err := app.SendDeploySignal(restartDomain)
249+ if err != nil {
250+ return nil, err
251+ }
252+ return &remote.RestartResult{Status: "queued", Domain: domainName}, nil
253+ }
254+
255+ if err := l.containerSvc.SyncContainers(ctx); err != nil {
256+ return nil, err
257+ }
258+ if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
259+ return nil, err
260+ }
261+
262+ return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil
263+}
264+
265+func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
266+ if l.registrySvc == nil {
267+ return nil, fmt.Errorf("local registry service unavailable")
268+ }
269+ return l.registrySvc.ListTags(ctx, repository)
270+}
271+
272+func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
273+ if l.backupSvc == nil {
274+ return nil, fmt.Errorf("local backup service unavailable")
275+ }
276+ jobs, err := l.backupSvc.ListBackups(ctx, backupDomain)
277+ if err != nil {
278+ return nil, err
279+ }
280+ return toDTOBackupJobs(jobs), nil
281+}
282+
283+func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
284+ if l.backupSvc == nil {
285+ return nil, fmt.Errorf("local backup service unavailable")
286+ }
287+ jobs, err := l.backupSvc.Status(ctx)
288+ if err != nil {
289+ return nil, err
290+ }
291+ return toDTOBackupJobs(jobs), nil
292+}
293+
294+func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
295+ if l.backupSvc == nil {
296+ return nil, fmt.Errorf("local backup service unavailable")
297+ }
298+ result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName)
299+ if err != nil {
300+ return nil, err
301+ }
302+ if result == nil {
303+ return &dto.BackupRunResponse{Status: "ok"}, nil
304+ }
305+ job := toDTOBackupJob(result.Job)
306+ return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil
307+}
308+
309+func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
310+ if l.backupSvc == nil {
311+ return nil, fmt.Errorf("local backup service unavailable")
312+ }
313+ dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain)
314+ if err != nil {
315+ return nil, err
316+ }
317+ out := make([]dto.DatabaseInfo, 0, len(dbs))
318+ for _, db := range dbs {
319+ out = append(out, dto.DatabaseInfo{
320+ Type: string(db.Type),
321+ Name: db.Name,
322+ Version: db.Version,
323+ Host: db.Host,
324+ Port: db.Port,
325+ ContainerID: db.ContainerID,
326+ ImageName: db.ImageName,
327+ })
328+ }
329+ return out, nil
330+}
331+
332+func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
333+ if l.logSvc == nil {
334+ return nil, fmt.Errorf("local log service unavailable")
335+ }
336+ return l.logSvc.GetProcessLogs(ctx, lines)
337+}
338+
339+func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
340+ if l.logSvc == nil {
341+ return nil, fmt.Errorf("local log service unavailable")
342+ }
343+ return l.logSvc.GetContainerLogs(ctx, logDomain, lines)
344+}
345+
346+func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
347+ if l.logSvc == nil {
348+ return nil, fmt.Errorf("local log service unavailable")
349+ }
350+ return l.logSvc.FollowProcessLogs(ctx, lines)
351+}
352+
353+func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
354+ if l.logSvc == nil {
355+ return nil, fmt.Errorf("local log service unavailable")
356+ }
357+ return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)
358+}
359+
360+func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
361+ out := make([]remote.RouteInfo, 0, len(routes))
362+ for _, route := range routes {
363+ attachments := make([]dto.Attachment, 0, len(route.Attachments))
364+ for _, attachment := range route.Attachments {
365+ attachments = append(attachments, dto.Attachment{
366+ Name: attachment.Name,
367+ Image: attachment.Image,
368+ ContainerID: attachment.ContainerID,
369+ Status: attachment.Status,
370+ Network: attachment.Network,
371+ })
372+ }
373+
374+ out = append(out, remote.RouteInfo{
375+ Domain: route.Domain,
376+ Image: route.Image,
377+ ContainerID: route.ContainerID,
378+ ContainerStatus: route.ContainerStatus,
379+ Network: route.Network,
380+ Attachments: attachments,
381+ })
382+ }
383+ return out
384+}
385+
386+func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
387+ out := make([]dto.BackupJob, 0, len(jobs))
388+ for _, job := range jobs {
389+ out = append(out, toDTOBackupJob(job))
390+ }
391+ return out
392+}
393+
394+func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
395+ var startedAt *time.Time
396+ if !job.StartedAt.IsZero() {
397+ t := job.StartedAt
398+ startedAt = &t
399+ }
400+ var completedAt *time.Time
401+ if !job.CompletedAt.IsZero() {
402+ t := job.CompletedAt
403+ completedAt = &t
404+ }
405+
406+ return dto.BackupJob{
407+ ID: job.ID,
408+ Domain: job.Domain,
409+ DBName: job.DBName,
410+ Schedule: string(job.Schedule),
411+ Type: string(job.Type),
412+ Status: string(job.Status),
413+ StartedAt: startedAt,
414+ CompletedAt: completedAt,
415+ SizeBytes: job.SizeBytes,
416+ Error: job.Error,
417+ }
418+}
A internal/adapters/in/cli/controlplane_local_test.go +108 -0
@@ -0,0 +1,108 @@
1+package cli
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
9+ "github.com/bnema/gordon/internal/domain"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestLocalControlPlane_GetStatus(t *testing.T) {
15+ t.Parallel()
16+
17+ configSvc := inmocks.NewMockConfigService(t)
18+ containerSvc := inmocks.NewMockContainerService(t)
19+
20+ ctx := context.Background()
21+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.local", Image: "repo/app:latest"}})
22+ configSvc.EXPECT().GetRegistryDomain().Return("registry.local")
23+ configSvc.EXPECT().GetRegistryPort().Return(5000)
24+ configSvc.EXPECT().GetServerPort().Return(80)
25+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
26+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true)
27+ containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
28+ "app.local": {ID: "abc123", Status: "running"},
29+ })
30+
31+ cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
32+ status, err := cp.GetStatus(ctx)
33+ require.NoError(t, err)
34+ require.Equal(t, 1, status.Routes)
35+ require.Equal(t, "registry.local", status.RegistryDomain)
36+ require.Equal(t, "running", status.ContainerStatus["app.local"])
37+}
38+
39+func TestLocalControlPlane_Backups(t *testing.T) {
40+ t.Parallel()
41+
42+ backupSvc := inmocks.NewMockBackupService(t)
43+ ctx := context.Background()
44+
45+ now := time.Now().UTC()
46+ jobs := []domain.BackupJob{{ID: "b1", Domain: "app.local", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: now}}
47+ backupSvc.EXPECT().ListBackups(mock.Anything, "app.local").Return(jobs, nil)
48+ backupSvc.EXPECT().Status(mock.Anything).Return(jobs, nil)
49+ backupSvc.EXPECT().RunBackup(mock.Anything, "app.local", "postgres").Return(&domain.BackupResult{Job: jobs[0]}, nil)
50+ backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.local").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Host: "postgres", Port: 5432}}, nil)
51+
52+ cp := &localControlPlane{backupSvc: backupSvc}
53+
54+ list, err := cp.ListBackups(ctx, "app.local")
55+ require.NoError(t, err)
56+ require.Len(t, list, 1)
57+
58+ status, err := cp.BackupStatus(ctx)
59+ require.NoError(t, err)
60+ require.Len(t, status, 1)
61+
62+ run, err := cp.RunBackup(ctx, "app.local", "postgres")
63+ require.NoError(t, err)
64+ require.NotNil(t, run.Backup)
65+ require.Equal(t, "b1", run.Backup.ID)
66+
67+ dbs, err := cp.DetectDatabases(ctx, "app.local")
68+ require.NoError(t, err)
69+ require.Len(t, dbs, 1)
70+ require.Equal(t, "postgres", dbs[0].Name)
71+}
72+
73+func TestLocalControlPlane_ListTags(t *testing.T) {
74+ t.Parallel()
75+
76+ registrySvc := inmocks.NewMockRegistryService(t)
77+ registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"v1.0.0", "latest"}, nil)
78+
79+ cp := &localControlPlane{registrySvc: registrySvc}
80+ tags, err := cp.ListTags(context.Background(), "repo/app")
81+ require.NoError(t, err)
82+ require.Equal(t, []string{"v1.0.0", "latest"}, tags)
83+}
84+
85+func TestLocalControlPlane_RestartWithAttachments(t *testing.T) {
86+ t.Parallel()
87+
88+ containerSvc := inmocks.NewMockContainerService(t)
89+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
90+ containerSvc.EXPECT().Restart(mock.Anything, "app.local", true).Return(nil)
91+
92+ cp := &localControlPlane{containerSvc: containerSvc}
93+ result, err := cp.Restart(context.Background(), "app.local", true)
94+ require.NoError(t, err)
95+ require.Equal(t, "app.local", result.Domain)
96+}
97+
98+func TestLocalControlPlane_GetContainerLogs(t *testing.T) {
99+ t.Parallel()
100+
101+ logSvc := inmocks.NewMockLogService(t)
102+ logSvc.EXPECT().GetContainerLogs(mock.Anything, "app.local", 50).Return([]string{"line1", "line2"}, nil)
103+
104+ cp := &localControlPlane{logSvc: logSvc}
105+ lines, err := cp.GetContainerLogs(context.Background(), "app.local", 50)
106+ require.NoError(t, err)
107+ require.Equal(t, []string{"line1", "line2"}, lines)
108+}
@@ -0,0 +1,108 @@
1+package cli
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
9+ "github.com/bnema/gordon/internal/domain"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestLocalControlPlane_GetStatus(t *testing.T) {
15+ t.Parallel()
16+
17+ configSvc := inmocks.NewMockConfigService(t)
18+ containerSvc := inmocks.NewMockContainerService(t)
19+
20+ ctx := context.Background()
21+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "app.local", Image: "repo/app:latest"}})
22+ configSvc.EXPECT().GetRegistryDomain().Return("registry.local")
23+ configSvc.EXPECT().GetRegistryPort().Return(5000)
24+ configSvc.EXPECT().GetServerPort().Return(80)
25+ configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
26+ configSvc.EXPECT().IsNetworkIsolationEnabled().Return(true)
27+ containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
28+ "app.local": {ID: "abc123", Status: "running"},
29+ })
30+
31+ cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
32+ status, err := cp.GetStatus(ctx)
33+ require.NoError(t, err)
34+ require.Equal(t, 1, status.Routes)
35+ require.Equal(t, "registry.local", status.RegistryDomain)
36+ require.Equal(t, "running", status.ContainerStatus["app.local"])
37+}
38+
39+func TestLocalControlPlane_Backups(t *testing.T) {
40+ t.Parallel()
41+
42+ backupSvc := inmocks.NewMockBackupService(t)
43+ ctx := context.Background()
44+
45+ now := time.Now().UTC()
46+ jobs := []domain.BackupJob{{ID: "b1", Domain: "app.local", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: now}}
47+ backupSvc.EXPECT().ListBackups(mock.Anything, "app.local").Return(jobs, nil)
48+ backupSvc.EXPECT().Status(mock.Anything).Return(jobs, nil)
49+ backupSvc.EXPECT().RunBackup(mock.Anything, "app.local", "postgres").Return(&domain.BackupResult{Job: jobs[0]}, nil)
50+ backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.local").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Host: "postgres", Port: 5432}}, nil)
51+
52+ cp := &localControlPlane{backupSvc: backupSvc}
53+
54+ list, err := cp.ListBackups(ctx, "app.local")
55+ require.NoError(t, err)
56+ require.Len(t, list, 1)
57+
58+ status, err := cp.BackupStatus(ctx)
59+ require.NoError(t, err)
60+ require.Len(t, status, 1)
61+
62+ run, err := cp.RunBackup(ctx, "app.local", "postgres")
63+ require.NoError(t, err)
64+ require.NotNil(t, run.Backup)
65+ require.Equal(t, "b1", run.Backup.ID)
66+
67+ dbs, err := cp.DetectDatabases(ctx, "app.local")
68+ require.NoError(t, err)
69+ require.Len(t, dbs, 1)
70+ require.Equal(t, "postgres", dbs[0].Name)
71+}
72+
73+func TestLocalControlPlane_ListTags(t *testing.T) {
74+ t.Parallel()
75+
76+ registrySvc := inmocks.NewMockRegistryService(t)
77+ registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"v1.0.0", "latest"}, nil)
78+
79+ cp := &localControlPlane{registrySvc: registrySvc}
80+ tags, err := cp.ListTags(context.Background(), "repo/app")
81+ require.NoError(t, err)
82+ require.Equal(t, []string{"v1.0.0", "latest"}, tags)
83+}
84+
85+func TestLocalControlPlane_RestartWithAttachments(t *testing.T) {
86+ t.Parallel()
87+
88+ containerSvc := inmocks.NewMockContainerService(t)
89+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
90+ containerSvc.EXPECT().Restart(mock.Anything, "app.local", true).Return(nil)
91+
92+ cp := &localControlPlane{containerSvc: containerSvc}
93+ result, err := cp.Restart(context.Background(), "app.local", true)
94+ require.NoError(t, err)
95+ require.Equal(t, "app.local", result.Domain)
96+}
97+
98+func TestLocalControlPlane_GetContainerLogs(t *testing.T) {
99+ t.Parallel()
100+
101+ logSvc := inmocks.NewMockLogService(t)
102+ logSvc.EXPECT().GetContainerLogs(mock.Anything, "app.local", 50).Return([]string{"line1", "line2"}, nil)
103+
104+ cp := &localControlPlane{logSvc: logSvc}
105+ lines, err := cp.GetContainerLogs(context.Background(), "app.local", 50)
106+ require.NoError(t, err)
107+ require.Equal(t, []string{"line1", "line2"}, lines)
108+}
A internal/adapters/in/cli/controlplane_remote.go +129 -0
@@ -0,0 +1,129 @@
1+package cli
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/adapters/dto"
7+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+type remoteControlPlane struct {
12+ client *remote.Client
13+}
14+
15+func NewRemoteControlPlane(client *remote.Client) ControlPlane {
16+ return &remoteControlPlane{client: client}
17+}
18+
19+func (r *remoteControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
20+ return r.client.ListRoutesWithDetails(ctx)
21+}
22+
23+func (r *remoteControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
24+ return r.client.GetHealth(ctx)
25+}
26+
27+func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
28+ return r.client.GetRoute(ctx, routeDomain)
29+}
30+
31+func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
32+ return r.client.AddRoute(ctx, route)
33+}
34+
35+func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
36+ return r.client.UpdateRoute(ctx, route)
37+}
38+
39+func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
40+ return r.client.RemoveRoute(ctx, routeDomain)
41+}
42+
43+func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
44+ return r.client.ListSecretsWithAttachments(ctx, secretDomain)
45+}
46+
47+func (r *remoteControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
48+ return r.client.SetSecrets(ctx, secretDomain, secrets)
49+}
50+
51+func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
52+ return r.client.DeleteSecret(ctx, secretDomain, key)
53+}
54+
55+func (r *remoteControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
56+ return r.client.SetAttachmentSecrets(ctx, domainName, service, secrets)
57+}
58+
59+func (r *remoteControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
60+ return r.client.DeleteAttachmentSecret(ctx, domainName, service, key)
61+}
62+
63+func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
64+ return r.client.GetAllAttachmentsConfig(ctx)
65+}
66+
67+func (r *remoteControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
68+ return r.client.GetAttachmentsConfig(ctx, domainOrGroup)
69+}
70+
71+func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
72+ return r.client.AddAttachment(ctx, domainOrGroup, image)
73+}
74+
75+func (r *remoteControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
76+ return r.client.RemoveAttachment(ctx, domainOrGroup, image)
77+}
78+
79+func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
80+ return r.client.GetStatus(ctx)
81+}
82+
83+func (r *remoteControlPlane) Reload(ctx context.Context) error {
84+ return r.client.Reload(ctx)
85+}
86+
87+func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
88+ return r.client.Deploy(ctx, deployDomain)
89+}
90+
91+func (r *remoteControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
92+ return r.client.Restart(ctx, restartDomain, withAttachments)
93+}
94+
95+func (r *remoteControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
96+ return r.client.ListTags(ctx, repository)
97+}
98+
99+func (r *remoteControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
100+ return r.client.ListBackups(ctx, backupDomain)
101+}
102+
103+func (r *remoteControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
104+ return r.client.BackupStatus(ctx)
105+}
106+
107+func (r *remoteControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
108+ return r.client.RunBackup(ctx, backupDomain, dbName)
109+}
110+
111+func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
112+ return r.client.DetectDatabases(ctx, backupDomain)
113+}
114+
115+func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
116+ return r.client.GetProcessLogs(ctx, lines)
117+}
118+
119+func (r *remoteControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
120+ return r.client.GetContainerLogs(ctx, logDomain, lines)
121+}
122+
123+func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
124+ return r.client.StreamProcessLogs(ctx, lines)
125+}
126+
127+func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
128+ return r.client.StreamContainerLogs(ctx, logDomain, lines)
129+}
@@ -0,0 +1,129 @@
1+package cli
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/adapters/dto"
7+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+type remoteControlPlane struct {
12+ client *remote.Client
13+}
14+
15+func NewRemoteControlPlane(client *remote.Client) ControlPlane {
16+ return &remoteControlPlane{client: client}
17+}
18+
19+func (r *remoteControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
20+ return r.client.ListRoutesWithDetails(ctx)
21+}
22+
23+func (r *remoteControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
24+ return r.client.GetHealth(ctx)
25+}
26+
27+func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
28+ return r.client.GetRoute(ctx, routeDomain)
29+}
30+
31+func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
32+ return r.client.AddRoute(ctx, route)
33+}
34+
35+func (r *remoteControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
36+ return r.client.UpdateRoute(ctx, route)
37+}
38+
39+func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
40+ return r.client.RemoveRoute(ctx, routeDomain)
41+}
42+
43+func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
44+ return r.client.ListSecretsWithAttachments(ctx, secretDomain)
45+}
46+
47+func (r *remoteControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
48+ return r.client.SetSecrets(ctx, secretDomain, secrets)
49+}
50+
51+func (r *remoteControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
52+ return r.client.DeleteSecret(ctx, secretDomain, key)
53+}
54+
55+func (r *remoteControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
56+ return r.client.SetAttachmentSecrets(ctx, domainName, service, secrets)
57+}
58+
59+func (r *remoteControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
60+ return r.client.DeleteAttachmentSecret(ctx, domainName, service, key)
61+}
62+
63+func (r *remoteControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
64+ return r.client.GetAllAttachmentsConfig(ctx)
65+}
66+
67+func (r *remoteControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
68+ return r.client.GetAttachmentsConfig(ctx, domainOrGroup)
69+}
70+
71+func (r *remoteControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
72+ return r.client.AddAttachment(ctx, domainOrGroup, image)
73+}
74+
75+func (r *remoteControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
76+ return r.client.RemoveAttachment(ctx, domainOrGroup, image)
77+}
78+
79+func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
80+ return r.client.GetStatus(ctx)
81+}
82+
83+func (r *remoteControlPlane) Reload(ctx context.Context) error {
84+ return r.client.Reload(ctx)
85+}
86+
87+func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
88+ return r.client.Deploy(ctx, deployDomain)
89+}
90+
91+func (r *remoteControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
92+ return r.client.Restart(ctx, restartDomain, withAttachments)
93+}
94+
95+func (r *remoteControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
96+ return r.client.ListTags(ctx, repository)
97+}
98+
99+func (r *remoteControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
100+ return r.client.ListBackups(ctx, backupDomain)
101+}
102+
103+func (r *remoteControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
104+ return r.client.BackupStatus(ctx)
105+}
106+
107+func (r *remoteControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
108+ return r.client.RunBackup(ctx, backupDomain, dbName)
109+}
110+
111+func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
112+ return r.client.DetectDatabases(ctx, backupDomain)
113+}
114+
115+func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
116+ return r.client.GetProcessLogs(ctx, lines)
117+}
118+
119+func (r *remoteControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
120+ return r.client.GetContainerLogs(ctx, logDomain, lines)
121+}
122+
123+func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
124+ return r.client.StreamProcessLogs(ctx, lines)
125+}
126+
127+func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
128+ return r.client.StreamContainerLogs(ctx, logDomain, lines)
129+}
A internal/adapters/in/cli/controlplane_remote_test.go +18 -0
@@ -0,0 +1,18 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7+)
8+
9+var _ ControlPlane = (*remoteControlPlane)(nil)
10+
11+func TestRemoteControlPlane_ImplementsInterface(t *testing.T) {
12+ t.Helper()
13+
14+ client := remote.NewClient("https://gordon.example.com")
15+ if NewRemoteControlPlane(client) == nil {
16+ t.Fatal("expected non-nil remote control-plane")
17+ }
18+}
@@ -0,0 +1,18 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7+)
8+
9+var _ ControlPlane = (*remoteControlPlane)(nil)
10+
11+func TestRemoteControlPlane_ImplementsInterface(t *testing.T) {
12+ t.Helper()
13+
14+ client := remote.NewClient("https://gordon.example.com")
15+ if NewRemoteControlPlane(client) == nil {
16+ t.Fatal("expected non-nil remote control-plane")
17+ }
18+}
A internal/adapters/in/cli/controlplane_resolver.go +37 -0
@@ -0,0 +1,37 @@
1+package cli
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/gordon/internal/app"
7+)
8+
9+type controlPlaneHandle struct {
10+ plane ControlPlane
11+ isRemote bool
12+ closeFn func() error
13+}
14+
15+func (h *controlPlaneHandle) close() {
16+ if h == nil || h.closeFn == nil {
17+ return
18+ }
19+ _ = h.closeFn()
20+}
21+
22+func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
23+ client, isRemote := GetRemoteClient()
24+ if isRemote {
25+ return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
26+ }
27+
28+ kernel, err := app.NewKernel(configPath)
29+ if err != nil {
30+ return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
31+ }
32+
33+ return &controlPlaneHandle{
34+ plane: NewLocalControlPlane(kernel),
35+ closeFn: kernel.Close,
36+ }, nil
37+}
@@ -0,0 +1,37 @@
1+package cli
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/gordon/internal/app"
7+)
8+
9+type controlPlaneHandle struct {
10+ plane ControlPlane
11+ isRemote bool
12+ closeFn func() error
13+}
14+
15+func (h *controlPlaneHandle) close() {
16+ if h == nil || h.closeFn == nil {
17+ return
18+ }
19+ _ = h.closeFn()
20+}
21+
22+func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
23+ client, isRemote := GetRemoteClient()
24+ if isRemote {
25+ return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
26+ }
27+
28+ kernel, err := app.NewKernel(configPath)
29+ if err != nil {
30+ return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
31+ }
32+
33+ return &controlPlaneHandle{
34+ plane: NewLocalControlPlane(kernel),
35+ closeFn: kernel.Close,
36+ }, nil
37+}
A internal/adapters/in/cli/parity_matrix_test.go +31 -0
@@ -0,0 +1,31 @@
1+package cli
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestLocalParityMatrix(t *testing.T) {
10+ checks := []struct {
11+ file string
12+ legacyText string
13+ command string
14+ }{
15+ {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"},
16+ {file: "rollback.go", legacyText: "rollback requires remote mode", command: "gordon rollback"},
17+ {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"},
18+ {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"},
19+ {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"},
20+ }
21+
22+ for _, check := range checks {
23+ content, err := os.ReadFile(check.file)
24+ if err != nil {
25+ t.Fatalf("failed to read %s: %v", check.file, err)
26+ }
27+ if strings.Contains(string(content), check.legacyText) {
28+ t.Fatalf("local parity gap: %s still blocks local mode (%q found in %s)", check.command, check.legacyText, check.file)
29+ }
30+ }
31+}
@@ -0,0 +1,31 @@
1+package cli
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestLocalParityMatrix(t *testing.T) {
10+ checks := []struct {
11+ file string
12+ legacyText string
13+ command string
14+ }{
15+ {file: "push.go", legacyText: "push requires remote mode", command: "gordon push"},
16+ {file: "rollback.go", legacyText: "rollback requires remote mode", command: "gordon rollback"},
17+ {file: "backup.go", legacyText: "backup commands require a configured remote target", command: "gordon backups"},
18+ {file: "routes.go", legacyText: "status command requires --remote flag or GORDON_REMOTE env var", command: "gordon status"},
19+ {file: "restart.go", legacyText: "local restart does not support --with-attachments; use --remote", command: "gordon restart --with-attachments"},
20+ }
21+
22+ for _, check := range checks {
23+ content, err := os.ReadFile(check.file)
24+ if err != nil {
25+ t.Fatalf("failed to read %s: %v", check.file, err)
26+ }
27+ if strings.Contains(string(content), check.legacyText) {
28+ t.Fatalf("local parity gap: %s still blocks local mode (%q found in %s)", check.command, check.legacyText, check.file)
29+ }
30+ }
31+}
M internal/adapters/in/cli/push.go +9 -9
@@ -10,7 +10,6 @@ import (
1010
1111 "github.com/spf13/cobra"
1212
13- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1413 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1514 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
1615 "github.com/bnema/gordon/pkg/validation"
@@ -74,17 +73,18 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe
7473 }
7574
7675 func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error {
77- client, isRemote := GetRemoteClient()
78- if !isRemote {
79- return fmt.Errorf("push requires remote mode")
76+ handle, err := resolveControlPlane(configPath)
77+ if err != nil {
78+ return err
8079 }
80+ defer handle.close()
8181
82- dockerfile, err := resolveDockerfile(dockerfile, build)
82+ dockerfile, err = resolveDockerfile(dockerfile, build)
8383 if err != nil {
8484 return err
8585 }
8686
87- route, err := client.GetRoute(ctx, pushDomain)
87+ route, err := handle.plane.GetRoute(ctx, pushDomain)
8888 if err != nil {
8989 return fmt.Errorf("failed to get route: %w", err)
9090 }
@@ -127,7 +127,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
127127 fmt.Println(styles.RenderSuccess("Push complete"))
128128
129129 if !noDeploy {
130- return deployAfterPush(ctx, client, pushDomain, noConfirm)
130+ return deployAfterPush(ctx, handle.plane, pushDomain, noConfirm)
131131 }
132132
133133 return nil
@@ -225,7 +225,7 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l
225225 return nil
226226 }
227227
228-func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain string, noConfirm bool) error {
228+func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error {
229229 if !noConfirm {
230230 confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
231231 if err != nil {
@@ -236,7 +236,7 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri
236236 }
237237 }
238238
239- result, err := client.Deploy(ctx, pushDomain)
239+ result, err := cp.Deploy(ctx, pushDomain)
240240 if err != nil {
241241 return fmt.Errorf("failed to deploy: %w", err)
242242 }
@@ -10,7 +10,6 @@ import (
10 10
11 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
12 12
13- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"13 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"14 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16 "github.com/bnema/gordon/pkg/validation"15 "github.com/bnema/gordon/pkg/validation"
@@ -74,17 +73,18 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe
74 }73 }
75 74
76 func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error {75 func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error {
77- client, isRemote := GetRemoteClient()76+ handle, err := resolveControlPlane(configPath)
78- if !isRemote {77+ if err != nil {
79- return fmt.Errorf("push requires remote mode")78+ return err
80 }79 }
80+ defer handle.close()
81 81
82- dockerfile, err := resolveDockerfile(dockerfile, build)82+ dockerfile, err = resolveDockerfile(dockerfile, build)
83 if err != nil {83 if err != nil {
84 return err84 return err
85 }85 }
86 86
87- route, err := client.GetRoute(ctx, pushDomain)87+ route, err := handle.plane.GetRoute(ctx, pushDomain)
88 if err != nil {88 if err != nil {
89 return fmt.Errorf("failed to get route: %w", err)89 return fmt.Errorf("failed to get route: %w", err)
90 }90 }
@@ -127,7 +127,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
127 fmt.Println(styles.RenderSuccess("Push complete"))127 fmt.Println(styles.RenderSuccess("Push complete"))
128 128
129 if !noDeploy {129 if !noDeploy {
130- return deployAfterPush(ctx, client, pushDomain, noConfirm)130+ return deployAfterPush(ctx, handle.plane, pushDomain, noConfirm)
131 }131 }
132 132
133 return nil133 return nil
@@ -225,7 +225,7 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l
225 return nil225 return nil
226 }226 }
227 227
228-func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain string, noConfirm bool) error {228+func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error {
229 if !noConfirm {229 if !noConfirm {
230 confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())230 confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
231 if err != nil {231 if err != nil {
@@ -236,7 +236,7 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri
236 }236 }
237 }237 }
238 238
239- result, err := client.Deploy(ctx, pushDomain)239+ result, err := cp.Deploy(ctx, pushDomain)
240 if err != nil {240 if err != nil {
241 return fmt.Errorf("failed to deploy: %w", err)241 return fmt.Errorf("failed to deploy: %w", err)
242 }242 }
M internal/adapters/in/cli/restart.go +6 -13
@@ -28,22 +28,15 @@ Examples:
2828 ctx := cmd.Context()
2929 restartDomain := args[0]
3030
31- client, isRemote := GetRemoteClient()
32- if !isRemote {
33- if withAttachments {
34- return fmt.Errorf("local restart does not support --with-attachments; use --remote")
35- }
36- domain, err := app.SendDeploySignal(restartDomain)
37- if err != nil {
38- return fmt.Errorf("failed to trigger local restart via deploy signal: %w", err)
39- }
40- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain)))
41- return nil
31+ handle, err := resolveControlPlane(configPath)
32+ if err != nil {
33+ return err
4234 }
35+ defer handle.close()
4336
44- result, err := client.Restart(ctx, restartDomain, withAttachments)
37+ result, err := handle.plane.Restart(ctx, restartDomain, withAttachments)
4538 if err != nil {
46- if !withAttachments && shouldFallbackToLocal(err) {
39+ if handle.isRemote && !withAttachments && shouldFallbackToLocal(err) {
4740 domain, localErr := app.SendDeploySignal(restartDomain)
4841 if localErr == nil {
4942 fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err)))
@@ -28,22 +28,15 @@ Examples:
28 ctx := cmd.Context()28 ctx := cmd.Context()
29 restartDomain := args[0]29 restartDomain := args[0]
30 30
31- client, isRemote := GetRemoteClient()31+ handle, err := resolveControlPlane(configPath)
32- if !isRemote {32+ if err != nil {
33- if withAttachments {33+ return err
34- return fmt.Errorf("local restart does not support --with-attachments; use --remote")
35- }
36- domain, err := app.SendDeploySignal(restartDomain)
37- if err != nil {
38- return fmt.Errorf("failed to trigger local restart via deploy signal: %w", err)
39- }
40- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain)))
41- return nil
42 }34 }
35+ defer handle.close()
43 36
44- result, err := client.Restart(ctx, restartDomain, withAttachments)37+ result, err := handle.plane.Restart(ctx, restartDomain, withAttachments)
45 if err != nil {38 if err != nil {
46- if !withAttachments && shouldFallbackToLocal(err) {39+ if handle.isRemote && !withAttachments && shouldFallbackToLocal(err) {
47 domain, localErr := app.SendDeploySignal(restartDomain)40 domain, localErr := app.SendDeploySignal(restartDomain)
48 if localErr == nil {41 if localErr == nil {
49 fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err)))42 fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err)))
M internal/adapters/in/cli/rollback.go +13 -13
@@ -10,7 +10,6 @@ import (
1010 "github.com/spf13/cobra"
1111 "golang.org/x/mod/semver"
1212
13- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1413 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1514 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
1615 "github.com/bnema/gordon/internal/domain"
@@ -40,12 +39,13 @@ Examples:
4039 }
4140
4241 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
43- client, isRemote := GetRemoteClient()
44- if !isRemote {
45- return fmt.Errorf("rollback requires remote mode")
42+ handle, err := resolveControlPlane(configPath)
43+ if err != nil {
44+ return err
4645 }
46+ defer handle.close()
4747
48- route, err := client.GetRoute(ctx, rollbackDomain)
48+ route, err := handle.plane.GetRoute(ctx, rollbackDomain)
4949 if err != nil {
5050 return fmt.Errorf("failed to get route: %w", err)
5151 }
@@ -55,7 +55,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
5555 return fmt.Errorf("cannot parse image name from route: %s", route.Image)
5656 }
5757
58- tags, err := fetchAndSortTags(ctx, client, imageName)
58+ tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
5959 if err != nil {
6060 return err
6161 }
@@ -70,11 +70,11 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
7070 return nil
7171 }
7272
73- return deploySelectedTag(ctx, client, route, rollbackDomain, imageName, selectedTag)
73+ return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
7474 }
7575
76-func fetchAndSortTags(ctx context.Context, client *remote.Client, imageName string) ([]string, error) {
77- tags, err := client.ListTags(ctx, imageName)
76+func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {
77+ tags, err := cp.ListTags(ctx, imageName)
7878 if err != nil {
7979 return nil, fmt.Errorf("failed to list tags: %w", err)
8080 }
@@ -154,22 +154,22 @@ func validateTagExists(targetTag string, tags []string) bool {
154154 return false
155155 }
156156
157-func deploySelectedTag(ctx context.Context, client *remote.Client, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {
157+func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {
158158 registry, _, _ := parseImageRef(route.Image)
159159 oldImage := route.Image
160160 route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
161161
162162 fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))
163163
164- if err := client.UpdateRoute(ctx, *route); err != nil {
164+ if err := cp.UpdateRoute(ctx, *route); err != nil {
165165 return fmt.Errorf("failed to update route: %w", err)
166166 }
167167
168- result, err := client.Deploy(ctx, rollbackDomain)
168+ result, err := cp.Deploy(ctx, rollbackDomain)
169169 if err != nil {
170170 // Attempt to revert route to previous image
171171 route.Image = oldImage
172- if revertErr := client.UpdateRoute(ctx, *route); revertErr != nil {
172+ if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
173173 fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
174174 return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
175175 }
@@ -10,7 +10,6 @@ import (
10 "github.com/spf13/cobra"10 "github.com/spf13/cobra"
11 "golang.org/x/mod/semver"11 "golang.org/x/mod/semver"
12 12
13- "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"13 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"14 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
@@ -40,12 +39,13 @@ Examples:
40 }39 }
41 40
42 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {41 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
43- client, isRemote := GetRemoteClient()42+ handle, err := resolveControlPlane(configPath)
44- if !isRemote {43+ if err != nil {
45- return fmt.Errorf("rollback requires remote mode")44+ return err
46 }45 }
46+ defer handle.close()
47 47
48- route, err := client.GetRoute(ctx, rollbackDomain)48+ route, err := handle.plane.GetRoute(ctx, rollbackDomain)
49 if err != nil {49 if err != nil {
50 return fmt.Errorf("failed to get route: %w", err)50 return fmt.Errorf("failed to get route: %w", err)
51 }51 }
@@ -55,7 +55,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
55 return fmt.Errorf("cannot parse image name from route: %s", route.Image)55 return fmt.Errorf("cannot parse image name from route: %s", route.Image)
56 }56 }
57 57
58- tags, err := fetchAndSortTags(ctx, client, imageName)58+ tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
59 if err != nil {59 if err != nil {
60 return err60 return err
61 }61 }
@@ -70,11 +70,11 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
70 return nil70 return nil
71 }71 }
72 72
73- return deploySelectedTag(ctx, client, route, rollbackDomain, imageName, selectedTag)73+ return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
74 }74 }
75 75
76-func fetchAndSortTags(ctx context.Context, client *remote.Client, imageName string) ([]string, error) {76+func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {
77- tags, err := client.ListTags(ctx, imageName)77+ tags, err := cp.ListTags(ctx, imageName)
78 if err != nil {78 if err != nil {
79 return nil, fmt.Errorf("failed to list tags: %w", err)79 return nil, fmt.Errorf("failed to list tags: %w", err)
80 }80 }
@@ -154,22 +154,22 @@ func validateTagExists(targetTag string, tags []string) bool {
154 return false154 return false
155 }155 }
156 156
157-func deploySelectedTag(ctx context.Context, client *remote.Client, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {157+func deploySelectedTag(ctx context.Context, cp ControlPlane, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {
158 registry, _, _ := parseImageRef(route.Image)158 registry, _, _ := parseImageRef(route.Image)
159 oldImage := route.Image159 oldImage := route.Image
160 route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)160 route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
161 161
162 fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))162 fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))
163 163
164- if err := client.UpdateRoute(ctx, *route); err != nil {164+ if err := cp.UpdateRoute(ctx, *route); err != nil {
165 return fmt.Errorf("failed to update route: %w", err)165 return fmt.Errorf("failed to update route: %w", err)
166 }166 }
167 167
168- result, err := client.Deploy(ctx, rollbackDomain)168+ result, err := cp.Deploy(ctx, rollbackDomain)
169 if err != nil {169 if err != nil {
170 // Attempt to revert route to previous image170 // Attempt to revert route to previous image
171 route.Image = oldImage171 route.Image = oldImage
172- if revertErr := client.UpdateRoute(ctx, *route); revertErr != nil {172+ if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil {
173 fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)173 fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
174 return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)174 return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
175 }175 }
M internal/adapters/in/cli/routes.go +5 -6
@@ -3,7 +3,6 @@ package cli
33 import (
44 "context"
55 "fmt"
6- "os"
76 "strings"
87
98 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -375,13 +374,13 @@ func newStatusCmd() *cobra.Command {
375374 RunE: func(cmd *cobra.Command, args []string) error {
376375 ctx := context.Background()
377376
378- client, isRemote := GetRemoteClient()
379- if !isRemote {
380- fmt.Println(styles.RenderError("status command requires --remote flag or GORDON_REMOTE env var"))
381- os.Exit(1)
377+ handle, err := resolveControlPlane(configPath)
378+ if err != nil {
379+ return err
382380 }
381+ defer handle.close()
383382
384- status, err := client.GetStatus(ctx)
383+ status, err := handle.plane.GetStatus(ctx)
385384 if err != nil {
386385 return fmt.Errorf("failed to get status: %w", err)
387386 }
@@ -3,7 +3,6 @@ package cli
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "os"
7 "strings"6 "strings"
8 7
9 "github.com/bnema/gordon/internal/adapters/in/cli/remote"8 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -375,13 +374,13 @@ func newStatusCmd() *cobra.Command {
375 RunE: func(cmd *cobra.Command, args []string) error {374 RunE: func(cmd *cobra.Command, args []string) error {
376 ctx := context.Background()375 ctx := context.Background()
377 376
378- client, isRemote := GetRemoteClient()377+ handle, err := resolveControlPlane(configPath)
379- if !isRemote {378+ if err != nil {
380- fmt.Println(styles.RenderError("status command requires --remote flag or GORDON_REMOTE env var"))379+ return err
381- os.Exit(1)
382 }380 }
381+ defer handle.close()
383 382
384- status, err := client.GetStatus(ctx)383+ status, err := handle.plane.GetStatus(ctx)
385 if err != nil {384 if err != nil {
386 return fmt.Errorf("failed to get status: %w", err)385 return fmt.Errorf("failed to get status: %w", err)
387 }386 }
A internal/app/kernel.go +104 -0
@@ -0,0 +1,104 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/zerowrap"
8+
9+ "github.com/bnema/gordon/internal/boundaries/in"
10+ configusecase "github.com/bnema/gordon/internal/usecase/config"
11+ secretsusecase "github.com/bnema/gordon/internal/usecase/secrets"
12+)
13+
14+// Kernel provides in-process service access for local CLI execution.
15+//
16+// It intentionally does not start HTTP servers or register signal handlers.
17+type Kernel struct {
18+ configSvc in.ConfigService
19+ secretSvc in.SecretService
20+ containerSvc in.ContainerService
21+ backupSvc in.BackupService
22+ registrySvc in.RegistryService
23+ healthSvc in.HealthService
24+ logSvc in.LogService
25+ cleanup func()
26+}
27+
28+// NewKernel initializes local services without starting server listeners.
29+func NewKernel(configPath string) (*Kernel, error) {
30+ ctx := context.Background()
31+ v, cfg, err := initConfig(configPath)
32+ if err != nil {
33+ return nil, err
34+ }
35+
36+ log, cleanup, err := initLogger(cfg)
37+ if err != nil {
38+ return nil, err
39+ }
40+ if cleanup == nil {
41+ cleanup = func() {}
42+ }
43+
44+ ctx = zerowrap.WithCtx(ctx, log)
45+
46+ // Prefer full service wiring so local CLI can execute the same operations
47+ // as remote mode without going through HTTP admin endpoints.
48+ if svc, fullErr := createServices(ctx, v, cfg, log); fullErr == nil {
49+ return &Kernel{
50+ configSvc: svc.configSvc,
51+ secretSvc: svc.secretSvc,
52+ containerSvc: svc.containerSvc,
53+ backupSvc: svc.backupSvc,
54+ registrySvc: svc.registrySvc,
55+ healthSvc: svc.healthSvc,
56+ logSvc: svc.logSvc,
57+ cleanup: cleanup,
58+ }, nil
59+ } else {
60+ log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")
61+ }
62+
63+ configSvc := configusecase.NewService(v, nil)
64+ if err := configSvc.Load(ctx); err != nil {
65+ cleanup()
66+ return nil, fmt.Errorf("failed to load configuration: %w", err)
67+ }
68+
69+ _, _, _, domainSecretStore, err := createDomainSecretStore(cfg, log)
70+ if err != nil {
71+ cleanup()
72+ return nil, fmt.Errorf("failed to create local secret store: %w", err)
73+ }
74+
75+ secretSvc := secretsusecase.NewService(domainSecretStore, log)
76+
77+ return &Kernel{
78+ configSvc: configSvc,
79+ secretSvc: secretSvc,
80+ cleanup: cleanup,
81+ }, nil
82+}
83+
84+func (k *Kernel) Close() error {
85+ if k == nil || k.cleanup == nil {
86+ return nil
87+ }
88+ k.cleanup()
89+ return nil
90+}
91+
92+func (k *Kernel) Config() in.ConfigService { return k.configSvc }
93+
94+func (k *Kernel) Secrets() in.SecretService { return k.secretSvc }
95+
96+func (k *Kernel) Container() in.ContainerService { return k.containerSvc }
97+
98+func (k *Kernel) Backup() in.BackupService { return k.backupSvc }
99+
100+func (k *Kernel) Registry() in.RegistryService { return k.registrySvc }
101+
102+func (k *Kernel) Health() in.HealthService { return k.healthSvc }
103+
104+func (k *Kernel) Logs() in.LogService { return k.logSvc }
@@ -0,0 +1,104 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/zerowrap"
8+
9+ "github.com/bnema/gordon/internal/boundaries/in"
10+ configusecase "github.com/bnema/gordon/internal/usecase/config"
11+ secretsusecase "github.com/bnema/gordon/internal/usecase/secrets"
12+)
13+
14+// Kernel provides in-process service access for local CLI execution.
15+//
16+// It intentionally does not start HTTP servers or register signal handlers.
17+type Kernel struct {
18+ configSvc in.ConfigService
19+ secretSvc in.SecretService
20+ containerSvc in.ContainerService
21+ backupSvc in.BackupService
22+ registrySvc in.RegistryService
23+ healthSvc in.HealthService
24+ logSvc in.LogService
25+ cleanup func()
26+}
27+
28+// NewKernel initializes local services without starting server listeners.
29+func NewKernel(configPath string) (*Kernel, error) {
30+ ctx := context.Background()
31+ v, cfg, err := initConfig(configPath)
32+ if err != nil {
33+ return nil, err
34+ }
35+
36+ log, cleanup, err := initLogger(cfg)
37+ if err != nil {
38+ return nil, err
39+ }
40+ if cleanup == nil {
41+ cleanup = func() {}
42+ }
43+
44+ ctx = zerowrap.WithCtx(ctx, log)
45+
46+ // Prefer full service wiring so local CLI can execute the same operations
47+ // as remote mode without going through HTTP admin endpoints.
48+ if svc, fullErr := createServices(ctx, v, cfg, log); fullErr == nil {
49+ return &Kernel{
50+ configSvc: svc.configSvc,
51+ secretSvc: svc.secretSvc,
52+ containerSvc: svc.containerSvc,
53+ backupSvc: svc.backupSvc,
54+ registrySvc: svc.registrySvc,
55+ healthSvc: svc.healthSvc,
56+ logSvc: svc.logSvc,
57+ cleanup: cleanup,
58+ }, nil
59+ } else {
60+ log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")
61+ }
62+
63+ configSvc := configusecase.NewService(v, nil)
64+ if err := configSvc.Load(ctx); err != nil {
65+ cleanup()
66+ return nil, fmt.Errorf("failed to load configuration: %w", err)
67+ }
68+
69+ _, _, _, domainSecretStore, err := createDomainSecretStore(cfg, log)
70+ if err != nil {
71+ cleanup()
72+ return nil, fmt.Errorf("failed to create local secret store: %w", err)
73+ }
74+
75+ secretSvc := secretsusecase.NewService(domainSecretStore, log)
76+
77+ return &Kernel{
78+ configSvc: configSvc,
79+ secretSvc: secretSvc,
80+ cleanup: cleanup,
81+ }, nil
82+}
83+
84+func (k *Kernel) Close() error {
85+ if k == nil || k.cleanup == nil {
86+ return nil
87+ }
88+ k.cleanup()
89+ return nil
90+}
91+
92+func (k *Kernel) Config() in.ConfigService { return k.configSvc }
93+
94+func (k *Kernel) Secrets() in.SecretService { return k.secretSvc }
95+
96+func (k *Kernel) Container() in.ContainerService { return k.containerSvc }
97+
98+func (k *Kernel) Backup() in.BackupService { return k.backupSvc }
99+
100+func (k *Kernel) Registry() in.RegistryService { return k.registrySvc }
101+
102+func (k *Kernel) Health() in.HealthService { return k.healthSvc }
103+
104+func (k *Kernel) Logs() in.LogService { return k.logSvc }
A internal/app/kernel_test.go +37 -0
@@ -0,0 +1,37 @@
1+package app
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestNewKernel(t *testing.T) {
13+ t.Parallel()
14+
15+ tmpDir := t.TempDir()
16+ cfgPath := filepath.Join(tmpDir, "gordon.toml")
17+ dataDir := filepath.Join(tmpDir, "data")
18+
19+ cfg := fmt.Sprintf(`[server]
20+gordon_domain = "gordon.local"
21+data_dir = %q
22+
23+[auth]
24+enabled = false
25+`, dataDir)
26+
27+ err := os.WriteFile(cfgPath, []byte(cfg), 0o600)
28+ require.NoError(t, err)
29+
30+ kernel, err := NewKernel(cfgPath)
31+ require.NoError(t, err)
32+ require.NotNil(t, kernel)
33+ t.Cleanup(func() { require.NoError(t, kernel.Close()) })
34+
35+ require.NotNil(t, kernel.Config())
36+ require.NotNil(t, kernel.Secrets())
37+}
@@ -0,0 +1,37 @@
1+package app
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestNewKernel(t *testing.T) {
13+ t.Parallel()
14+
15+ tmpDir := t.TempDir()
16+ cfgPath := filepath.Join(tmpDir, "gordon.toml")
17+ dataDir := filepath.Join(tmpDir, "data")
18+
19+ cfg := fmt.Sprintf(`[server]
20+gordon_domain = "gordon.local"
21+data_dir = %q
22+
23+[auth]
24+enabled = false
25+`, dataDir)
26+
27+ err := os.WriteFile(cfgPath, []byte(cfg), 0o600)
28+ require.NoError(t, err)
29+
30+ kernel, err := NewKernel(cfgPath)
31+ require.NoError(t, err)
32+ require.NotNil(t, kernel)
33+ t.Cleanup(func() { require.NoError(t, kernel.Close()) })
34+
35+ require.NotNil(t, kernel.Config())
36+ require.NotNil(t, kernel.Secrets())
37+}
M wiki/guides/remote-cli.md +2 -0
@@ -2,6 +2,8 @@
22
33 Manage Gordon instances remotely using the CLI with the `--remote` flag or saved remotes.
44
5+By default (without `--remote`), management commands execute locally through in-process services.
6+
57 ## What You'll Learn
68
79 - Using global `--remote` and `--token` flags
@@ -2,6 +2,8 @@
2 2
3 Manage Gordon instances remotely using the CLI with the `--remote` flag or saved remotes.3 Manage Gordon instances remotely using the CLI with the `--remote` flag or saved remotes.
4 4
5+By default (without `--remote`), management commands execute locally through in-process services.
6+
5 ## What You'll Learn7 ## What You'll Learn
6 8
7 - Using global `--remote` and `--token` flags9 - Using global `--remote` and `--token` flags

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