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 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Manage backups for database attachments. | |
| 4 | 4 | ||
| 5 | - | ## gordon backup | |
| 5 | + | ## gordon backups | |
| 6 | 6 | ||
| 7 | 7 | ```bash | |
| 8 | - | gordon backup <subcommand> | |
| 8 | + | gordon backups <subcommand> | |
| 9 | 9 | ``` | |
| 10 | 10 | ||
| 11 | 11 | Subcommands: | |
| @@ -15,9 +15,12 @@ Subcommands: | |||
| 15 | 15 | - `detect <domain>` - Detect supported databases for a domain | |
| 16 | 16 | - `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 | 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 | 29 | ```bash | |
| 27 | 30 | # List all backups | |
| 28 | - | gordon backup list | |
| 31 | + | gordon backups list | |
| 29 | 32 | ||
| 30 | 33 | # List backups for one domain | |
| 31 | - | gordon backup list app.example.com | |
| 34 | + | gordon backups list app.example.com | |
| 32 | 35 | ||
| 33 | 36 | # Trigger backup (auto-detect when exactly one DB attachment exists) | |
| 34 | - | gordon backup run app.example.com | |
| 37 | + | gordon backups run app.example.com | |
| 35 | 38 | ||
| 36 | 39 | # Trigger backup for specific attachment name | |
| 37 | - | gordon backup run app.example.com --db postgres | |
| 40 | + | gordon backups run app.example.com --db postgres | |
| 38 | 41 | ||
| 39 | 42 | # Detect DB attachments | |
| 40 | - | gordon backup detect app.example.com | |
| 43 | + | gordon backups detect app.example.com | |
| 41 | 44 | ||
| 42 | 45 | # Status summary | |
| 43 | - | gordon backup status | |
| 46 | + | gordon backups status | |
| 44 | 47 | ``` | |
| 45 | 48 | ||
| 46 | 49 | ## Output Columns | |
| @@ -2,10 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | Manage backups for database attachments. | 3 | Manage backups for database attachments. | ||
| 4 | 4 | ||||
| 5 | - | ## gordon backup | 5 | + | ## gordon backups |
| 6 | 6 | ||||
| 7 | ```bash | 7 | ```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 domain | 15 | - `detect <domain>` - Detect supported databases for a domain | ||
| 16 | - `status` - Show backup status across domains | 16 | - `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 | ```bash | 29 | ```bash | ||
| 27 | # List all backups | 30 | # List all backups | ||
| 28 | - | gordon backup list | 31 | + | gordon backups list |
| 29 | 32 | ||||
| 30 | # List backups for one domain | 33 | # List backups for one domain | ||
| 31 | - | gordon backup list app.example.com | 34 | + | 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.com | 37 | + | gordon backups run app.example.com |
| 35 | 38 | ||||
| 36 | # Trigger backup for specific attachment name | 39 | # Trigger backup for specific attachment name | ||
| 37 | - | gordon backup run app.example.com --db postgres | 40 | + | gordon backups run app.example.com --db postgres |
| 38 | 41 | ||||
| 39 | # Detect DB attachments | 42 | # Detect DB attachments | ||
| 40 | - | gordon backup detect app.example.com | 43 | + | gordon backups detect app.example.com |
| 41 | 44 | ||||
| 42 | # Status summary | 45 | # Status summary | ||
| 43 | - | gordon backup status | 46 | + | gordon backups status |
| 44 | ``` | 47 | ``` | ||
| 45 | 48 | ||||
| 46 | ## Output Columns | 49 | ## 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: | |||
| 17 | 17 | ||
| 18 | 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 | 22 | | Command | Description | Documentation | | |
| 21 | 23 | |---------|-------------|---------------| | |
| 22 | 24 | | `gordon routes` | Manage routes | [routes](./routes.md) | | |
| @@ -29,7 +31,7 @@ Commands are organized by where they run: | |||
| 29 | 31 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| 30 | 32 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |
| 31 | 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 | 36 | ## Client Commands | |
| 35 | 37 | ||
| @@ -75,10 +77,10 @@ gordon logs myapp.local -f # Follow container logs | |||
| 75 | 77 | gordon version | |
| 76 | 78 | ||
| 77 | 79 | # 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 | |
| 82 | 84 | ||
| 83 | 85 | # Authentication | |
| 84 | 86 | 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 Commands | 36 | ## Client Commands | ||
| 35 | 37 | ||||
| @@ -75,10 +77,10 @@ gordon logs myapp.local -f # Follow container logs | |||||
| 75 | gordon version | 77 | gordon version | ||
| 76 | 78 | ||||
| 77 | # Backups | 79 | # Backups | ||
| 78 | - | gordon backup list | 80 | + | gordon backups list |
| 79 | - | gordon backup run app.example.com | 81 | + | gordon backups run app.example.com |
| 80 | - | gordon backup detect app.example.com | 82 | + | gordon backups detect app.example.com |
| 81 | - | gordon backup status | 83 | + | gordon backups status |
| 82 | 84 | ||||
| 83 | # Authentication | 85 | # Authentication | ||
| 84 | gordon auth token generate --subject ci-bot --expiry 0 | 86 | 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. | |||
| 7 | 7 | Display server configuration and container status for all routes. | |
| 8 | 8 | ||
| 9 | 9 | ```bash | |
| 10 | + | gordon status | |
| 10 | 11 | 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 | 19 | ### Output | |
| 16 | 20 | ||
| @@ -70,9 +74,12 @@ The status command uses global flags for remote access: | |||
| 70 | 74 | ||
| 71 | 75 | ## Examples | |
| 72 | 76 | ||
| 73 | - | ### Check Remote Server Status | |
| 77 | + | ### Check Local or Remote Status | |
| 74 | 78 | ||
| 75 | 79 | ```bash | |
| 80 | + | # Local | |
| 81 | + | gordon status | |
| 82 | + | ||
| 76 | 83 | # Using flags | |
| 77 | 84 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | |
| 78 | 85 | ||
| @@ -89,9 +96,9 @@ gordon status | |||
| 89 | 96 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | grep -E "(running|stopped|exited)" | |
| 90 | 97 | ``` | |
| 91 | 98 | ||
| 92 | - | ## Required Permissions | |
| 99 | + | ## 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 | 103 | ```bash | |
| 97 | 104 | # 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 | ```bash | 9 | ```bash | ||
| 10 | + | gordon status | |||
| 10 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | 11 | 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 | ### Output | 19 | ### Output | ||
| 16 | 20 | ||||
| @@ -70,9 +74,12 @@ The status command uses global flags for remote access: | |||||
| 70 | 74 | ||||
| 71 | ## Examples | 75 | ## Examples | ||
| 72 | 76 | ||||
| 73 | - | ### Check Remote Server Status | 77 | + | ### Check Local or Remote Status |
| 74 | 78 | ||||
| 75 | ```bash | 79 | ```bash | ||
| 80 | + | # Local | |||
| 81 | + | gordon status | |||
| 82 | + | ||||
| 76 | # Using flags | 83 | # Using flags | ||
| 77 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | 84 | 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 Permissions | 99 | + | ## 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 | ```bash | 103 | ```bash | ||
| 97 | # Generate token with required scope | 104 | # Generate token with required scope | ||
M docs/config/backups.md +1 -1
| @@ -6,7 +6,7 @@ Configure automatic PostgreSQL logical backups for attachment containers. | |||
| 6 | 6 | ||
| 7 | 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 | 10 | - Store backup artifacts on the Gordon host filesystem | |
| 11 | 11 | - Apply retention policies per schedule tier | |
| 12 | 12 | ||
| @@ -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 filesystem | 10 | - Store backup artifacts on the Gordon host filesystem | ||
| 11 | - Apply retention policies per schedule tier | 11 | - Apply retention policies per schedule tier | ||
| 12 | 12 | ||||
M internal/adapters/in/cli/backup.go +26 -20
| @@ -12,11 +12,13 @@ import ( | |||
| 12 | 12 | // newBackupCmd creates the backup command group. | |
| 13 | 13 | func newBackupCmd() *cobra.Command { | |
| 14 | 14 | 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. | |
| 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 | 24 | cmd.AddCommand(newBackupListCmd()) | |
| @@ -33,17 +35,18 @@ func newBackupListCmd() *cobra.Command { | |||
| 33 | 35 | Short: "List backups", | |
| 34 | 36 | Args: cobra.MaximumNArgs(1), | |
| 35 | 37 | 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 | |
| 39 | 41 | } | |
| 42 | + | defer handle.close() | |
| 40 | 43 | ||
| 41 | 44 | domainName := "" | |
| 42 | 45 | if len(args) == 1 { | |
| 43 | 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 | 50 | if err != nil { | |
| 48 | 51 | return fmt.Errorf("failed to list backups: %w", err) | |
| 49 | 52 | } | |
| @@ -79,12 +82,13 @@ func newBackupRunCmd() *cobra.Command { | |||
| 79 | 82 | Short: "Run backup now", | |
| 80 | 83 | Args: cobra.ExactArgs(1), | |
| 81 | 84 | 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 | |
| 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 | 92 | if err != nil { | |
| 89 | 93 | return fmt.Errorf("failed to run backup: %w", err) | |
| 90 | 94 | } | |
| @@ -117,12 +121,13 @@ func newBackupDetectCmd() *cobra.Command { | |||
| 117 | 121 | Short: "Detect databases for domain", | |
| 118 | 122 | Args: cobra.ExactArgs(1), | |
| 119 | 123 | 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 | |
| 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 | 131 | if err != nil { | |
| 127 | 132 | return fmt.Errorf("failed to detect databases: %w", err) | |
| 128 | 133 | } | |
| @@ -155,12 +160,13 @@ func newBackupStatusCmd() *cobra.Command { | |||
| 155 | 160 | Use: "status", | |
| 156 | 161 | Short: "Show backup status", | |
| 157 | 162 | 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 | |
| 161 | 166 | } | |
| 167 | + | defer handle.close() | |
| 162 | 168 | ||
| 163 | - | jobs, err := client.BackupStatus(cmd.Context()) | |
| 169 | + | jobs, err := handle.plane.BackupStatus(cmd.Context()) | |
| 164 | 170 | if err != nil { | |
| 165 | 171 | return fmt.Errorf("failed to get backup status: %w", err) | |
| 166 | 172 | } | |
| @@ -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 ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/spf13/cobra" | |
| 12 | 12 | ||
| 13 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 14 | 13 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 15 | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 16 | 15 | "github.com/bnema/gordon/pkg/validation" | |
| @@ -74,17 +73,18 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe | |||
| 74 | 73 | } | |
| 75 | 74 | ||
| 76 | 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() | |
| 78 | - | if !isRemote { | |
| 79 | - | return fmt.Errorf("push requires remote mode") | |
| 76 | + | handle, err := resolveControlPlane(configPath) | |
| 77 | + | if err != nil { | |
| 78 | + | return err | |
| 80 | 79 | } | |
| 80 | + | defer handle.close() | |
| 81 | 81 | ||
| 82 | - | dockerfile, err := resolveDockerfile(dockerfile, build) | |
| 82 | + | dockerfile, err = resolveDockerfile(dockerfile, build) | |
| 83 | 83 | if err != nil { | |
| 84 | 84 | return err | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | - | route, err := client.GetRoute(ctx, pushDomain) | |
| 87 | + | route, err := handle.plane.GetRoute(ctx, pushDomain) | |
| 88 | 88 | if err != nil { | |
| 89 | 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 | 127 | fmt.Println(styles.RenderSuccess("Push complete")) | |
| 128 | 128 | ||
| 129 | 129 | if !noDeploy { | |
| 130 | - | return deployAfterPush(ctx, client, pushDomain, noConfirm) | |
| 130 | + | return deployAfterPush(ctx, handle.plane, pushDomain, noConfirm) | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | 133 | return nil | |
| @@ -225,7 +225,7 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l | |||
| 225 | 225 | 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 | 229 | if !noConfirm { | |
| 230 | 230 | confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes()) | |
| 231 | 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 | 240 | if err != nil { | |
| 241 | 241 | return fmt.Errorf("failed to deploy: %w", err) | |
| 242 | 242 | } | |
| @@ -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 err | 84 | 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 nil | 133 | return nil | ||
| @@ -225,7 +225,7 @@ func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, l | |||||
| 225 | return nil | 225 | 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: | |||
| 28 | 28 | ctx := cmd.Context() | |
| 29 | 29 | restartDomain := args[0] | |
| 30 | 30 | ||
| 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 | |
| 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 | 38 | if err != nil { | |
| 46 | - | if !withAttachments && shouldFallbackToLocal(err) { | |
| 39 | + | if handle.isRemote && !withAttachments && shouldFallbackToLocal(err) { | |
| 47 | 40 | domain, localErr := app.SendDeploySignal(restartDomain) | |
| 48 | 41 | if localErr == nil { | |
| 49 | 42 | 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 ( | |||
| 10 | 10 | "github.com/spf13/cobra" | |
| 11 | 11 | "golang.org/x/mod/semver" | |
| 12 | 12 | ||
| 13 | - | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 14 | 13 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 15 | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 16 | 15 | "github.com/bnema/gordon/internal/domain" | |
| @@ -40,12 +39,13 @@ Examples: | |||
| 40 | 39 | } | |
| 41 | 40 | ||
| 42 | 41 | 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 | |
| 46 | 45 | } | |
| 46 | + | defer handle.close() | |
| 47 | 47 | ||
| 48 | - | route, err := client.GetRoute(ctx, rollbackDomain) | |
| 48 | + | route, err := handle.plane.GetRoute(ctx, rollbackDomain) | |
| 49 | 49 | if err != nil { | |
| 50 | 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 | 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 | 59 | if err != nil { | |
| 60 | 60 | return err | |
| 61 | 61 | } | |
| @@ -70,11 +70,11 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||
| 70 | 70 | 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) { | |
| 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) | |
| 78 | 78 | if err != nil { | |
| 79 | 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 | 154 | 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 | 158 | registry, _, _ := parseImageRef(route.Image) | |
| 159 | 159 | oldImage := route.Image | |
| 160 | 160 | route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag) | |
| 161 | 161 | ||
| 162 | 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 | 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 | 169 | if err != nil { | |
| 170 | 170 | // Attempt to revert route to previous image | |
| 171 | 171 | route.Image = oldImage | |
| 172 | - | if revertErr := client.UpdateRoute(ctx, *route); revertErr != nil { | |
| 172 | + | if revertErr := cp.UpdateRoute(ctx, *route); revertErr != nil { | |
| 173 | 173 | fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr) | |
| 174 | 174 | return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err) | |
| 175 | 175 | } | |
| @@ -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 err | 60 | return err | ||
| 61 | } | 61 | } | ||
| @@ -70,11 +70,11 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||||
| 70 | return nil | 70 | 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 false | 154 | 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.Image | 159 | 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 image | 170 | // Attempt to revert route to previous image | ||
| 171 | route.Image = oldImage | 171 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "os" | |
| 7 | 6 | "strings" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -375,13 +374,13 @@ func newStatusCmd() *cobra.Command { | |||
| 375 | 374 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 376 | 375 | ctx := context.Background() | |
| 377 | 376 | ||
| 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 | |
| 382 | 380 | } | |
| 381 | + | defer handle.close() | |
| 383 | 382 | ||
| 384 | - | status, err := client.GetStatus(ctx) | |
| 383 | + | status, err := handle.plane.GetStatus(ctx) | |
| 385 | 384 | if err != nil { | |
| 386 | 385 | return fmt.Errorf("failed to get status: %w", err) | |
| 387 | 386 | } | |
| @@ -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 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 7 | ## What You'll Learn | |
| 6 | 8 | ||
| 7 | 9 | - 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 Learn | 7 | ## What You'll Learn | ||
| 6 | 8 | ||||
| 7 | - Using global `--remote` and `--token` flags | 9 | - Using global `--remote` and `--token` flags | ||