Skip to content

Commit 22f9c21

Browse files
authored
Merge pull request #66 from intercube/feature/support-cluster-db-sync-target
[FEATURE] Support separate database SSH targets during sync
2 parents 0c7c24f + 60a622d commit 22f9c21

5 files changed

Lines changed: 355 additions & 13 deletions

File tree

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,32 @@ Behavior:
7171
- always fetches current site inventory at runtime
7272
- interactive target selection when no argument is passed
7373
- argument auto-resolves against site ID/domain/server/user when possible
74-
- stores only file path mappings in config (`sync.files.items`)
75-
- database details are requested interactively for each run (not persisted)
74+
- stores file path mappings in config (`sync.files.items`)
75+
- database connection details are requested interactively for each run (not persisted)
76+
- database sync uses the selected target server by default, with an optional
77+
database SSH host override for clustered setups where files and MySQL live on
78+
separate servers
79+
80+
Single-server environments do not need extra sync configuration. For clustered
81+
targets, add the database SSH defaults to `.intercube.yaml`:
82+
83+
```yaml
84+
sync:
85+
files:
86+
items:
87+
- source: /var/www/site/
88+
target: /var/www/site/
89+
database:
90+
target_ssh:
91+
host: production-sql.example.com
92+
user: deploy
93+
port: 22
94+
```
95+
96+
When `sync.database.target_ssh.host` is set, `intercube sync --database`
97+
pre-fills the database SSH prompt with that host. The `user` and `port` values
98+
are optional; when omitted, the selected target server's SSH user and port are
99+
used.
76100

77101
### Context-aware defaults
78102

cmd/sync-config_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/spf13/viper"
8+
)
9+
10+
func TestLoadSyncSettingsDecodesDatabaseTargetSSH(t *testing.T) {
11+
viper.Reset()
12+
t.Cleanup(viper.Reset)
13+
14+
viper.SetConfigType("yaml")
15+
err := viper.ReadConfig(strings.NewReader(`
16+
sync:
17+
database:
18+
target_ssh:
19+
host: production-sql.example.com
20+
user: deploy
21+
port: 2200
22+
`))
23+
if err != nil {
24+
t.Fatalf("unable to read config: %v", err)
25+
}
26+
27+
settings, err := loadSyncSettings()
28+
if err != nil {
29+
t.Fatalf("unable to load sync settings: %v", err)
30+
}
31+
32+
if settings.Database.TargetSSH.Host != "production-sql.example.com" {
33+
t.Fatalf("unexpected database SSH host: %q", settings.Database.TargetSSH.Host)
34+
}
35+
if settings.Database.TargetSSH.User != "deploy" {
36+
t.Fatalf("unexpected database SSH user: %q", settings.Database.TargetSSH.User)
37+
}
38+
if settings.Database.TargetSSH.Port != 2200 {
39+
t.Fatalf("unexpected database SSH port: %d", settings.Database.TargetSSH.Port)
40+
}
41+
if settings.Files.Items == nil {
42+
t.Fatalf("expected file sync items to default to an empty slice")
43+
}
44+
}

cmd/sync-database.go

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,17 @@ type mysqlSyncConfig struct {
2121
TargetHost string
2222
TargetPort int
2323
TargetPasswordEnv string
24+
DatabaseSSH syncSSHTarget
2425
DumpFlags []string
2526
}
2627

27-
func runDatabaseSync(cmd *cobra.Command, target ResolvedSyncTarget, _ *SyncSettings, dryRun bool, autoApprove bool) error {
28+
type syncSSHTarget struct {
29+
Host string
30+
Username string
31+
Port int
32+
}
33+
34+
func runDatabaseSync(cmd *cobra.Command, target ResolvedSyncTarget, settings *SyncSettings, dryRun bool, autoApprove bool) error {
2835
if isNonInteractiveMode() {
2936
return fmt.Errorf("database sync requires interactive prompts in current implementation; run with an interactive terminal")
3037
}
@@ -36,18 +43,18 @@ func runDatabaseSync(cmd *cobra.Command, target ResolvedSyncTarget, _ *SyncSetti
3643
return err
3744
}
3845

39-
databaseConfig, err := promptMySQLSyncConfig(target)
46+
databaseConfig, err := promptMySQLSyncConfig(target, settings)
4047
if err != nil {
4148
return err
4249
}
4350

44-
if isSameDatabaseTarget(target, databaseConfig) {
51+
if isSameDatabaseTarget(databaseConfig) {
4552
return fmt.Errorf("source and target resolve to the same database destination")
4653
}
4754

4855
fmt.Println("Database sync plan:")
4956
fmt.Printf(" Source: %s@%s:%d/%s\n", databaseConfig.SourceUser, databaseConfig.SourceHost, databaseConfig.SourcePort, databaseConfig.SourceDatabase)
50-
fmt.Printf(" Target: %s@%s:%d/%s (via %s@%s:%d)\n", databaseConfig.TargetUser, databaseConfig.TargetHost, databaseConfig.TargetPort, databaseConfig.TargetDatabase, target.Username, target.Host, target.Port)
57+
fmt.Printf(" Target: %s@%s:%d/%s (via %s@%s:%d)\n", databaseConfig.TargetUser, databaseConfig.TargetHost, databaseConfig.TargetPort, databaseConfig.TargetDatabase, databaseConfig.DatabaseSSH.Username, databaseConfig.DatabaseSSH.Host, databaseConfig.DatabaseSSH.Port)
5158

5259
if !autoApprove {
5360
confirmed, confirmErr := promptYesNo("Continue with MySQL import into target?")
@@ -73,7 +80,7 @@ func runDatabaseSync(cmd *cobra.Command, target ResolvedSyncTarget, _ *SyncSetti
7380

7481
dumpArgs := buildMySQLDumpArgs(databaseConfig)
7582
remoteCommand := buildRemoteMySQLImportCommand(databaseConfig, targetPassword)
76-
sshArgs := []string{"-p", strconv.Itoa(target.Port), fmt.Sprintf("%s@%s", target.Username, target.Host), remoteCommand}
83+
sshArgs := buildDatabaseSSHArgs(databaseConfig.DatabaseSSH, remoteCommand)
7784

7885
fmt.Printf("Running: MYSQL_PWD=<hidden> mysqldump %s | ssh %s\n", strings.Join(dumpArgs, " "), strings.Join(sshArgs, " "))
7986

@@ -122,7 +129,7 @@ func ensureCommandAvailable(name string) error {
122129
return nil
123130
}
124131

125-
func promptMySQLSyncConfig(target ResolvedSyncTarget) (mysqlSyncConfig, error) {
132+
func promptMySQLSyncConfig(target ResolvedSyncTarget, settings *SyncSettings) (mysqlSyncConfig, error) {
126133
sourceDatabase, err := promptText("Source MySQL database", "", requiredValue, 0)
127134
if err != nil {
128135
return mysqlSyncConfig{}, err
@@ -173,6 +180,11 @@ func promptMySQLSyncConfig(target ResolvedSyncTarget) (mysqlSyncConfig, error) {
173180
return mysqlSyncConfig{}, err
174181
}
175182

183+
databaseSSH, err := promptDatabaseSSHTarget(target, settings)
184+
if err != nil {
185+
return mysqlSyncConfig{}, err
186+
}
187+
176188
flagsRaw, err := promptText("Extra mysqldump flags (space-separated, optional)", "--single-transaction --quick", optionalValue, 0)
177189
if err != nil {
178190
return mysqlSyncConfig{}, err
@@ -189,10 +201,89 @@ func promptMySQLSyncConfig(target ResolvedSyncTarget) (mysqlSyncConfig, error) {
189201
TargetHost: strings.TrimSpace(targetHost),
190202
TargetPort: targetPort,
191203
TargetPasswordEnv: strings.TrimSpace(targetPasswordEnv),
204+
DatabaseSSH: databaseSSH,
192205
DumpFlags: strings.Fields(strings.TrimSpace(flagsRaw)),
193206
}, nil
194207
}
195208

209+
func promptDatabaseSSHTarget(target ResolvedSyncTarget, settings *SyncSettings) (syncSSHTarget, error) {
210+
defaultTarget := defaultDatabaseSSHTarget(target)
211+
configuredTarget, hasConfiguredTarget := configuredDatabaseSSHTarget(target, settings)
212+
213+
hostDefault := ""
214+
if hasConfiguredTarget {
215+
hostDefault = configuredTarget.Host
216+
}
217+
218+
host, err := promptText("Database SSH host override (blank = selected target)", hostDefault, optionalValue, 0)
219+
if err != nil {
220+
return syncSSHTarget{}, err
221+
}
222+
if strings.TrimSpace(host) == "" {
223+
return defaultTarget, nil
224+
}
225+
226+
usernameDefault := defaultTarget.Username
227+
portDefault := defaultTarget.Port
228+
if hasConfiguredTarget {
229+
usernameDefault = configuredTarget.Username
230+
portDefault = configuredTarget.Port
231+
}
232+
233+
username, err := promptText("Database SSH user", usernameDefault, requiredValue, 0)
234+
if err != nil {
235+
return syncSSHTarget{}, err
236+
}
237+
238+
port, err := promptPort("Database SSH port", strconv.Itoa(portDefault))
239+
if err != nil {
240+
return syncSSHTarget{}, err
241+
}
242+
243+
return syncSSHTarget{
244+
Host: strings.TrimSpace(host),
245+
Username: strings.TrimSpace(username),
246+
Port: port,
247+
}, nil
248+
}
249+
250+
func configuredDatabaseSSHTarget(target ResolvedSyncTarget, settings *SyncSettings) (syncSSHTarget, bool) {
251+
defaultTarget := defaultDatabaseSSHTarget(target)
252+
if settings == nil {
253+
return defaultTarget, false
254+
}
255+
256+
configured := settings.Database.TargetSSH
257+
host := strings.TrimSpace(configured.Host)
258+
if host == "" {
259+
return defaultTarget, false
260+
}
261+
262+
username := strings.TrimSpace(configured.User)
263+
if username == "" {
264+
username = defaultTarget.Username
265+
}
266+
267+
port := configured.Port
268+
if port <= 0 {
269+
port = defaultTarget.Port
270+
}
271+
272+
return syncSSHTarget{
273+
Host: host,
274+
Username: username,
275+
Port: port,
276+
}, true
277+
}
278+
279+
func defaultDatabaseSSHTarget(target ResolvedSyncTarget) syncSSHTarget {
280+
return syncSSHTarget{
281+
Host: strings.TrimSpace(target.Host),
282+
Username: strings.TrimSpace(target.Username),
283+
Port: target.Port,
284+
}
285+
}
286+
196287
func promptPort(label, defaultValue string) (int, error) {
197288
value, err := promptText(label, defaultValue, requiredValue, 0)
198289
if err != nil {
@@ -230,23 +321,42 @@ func buildRemoteMySQLImportCommand(config mysqlSyncConfig, targetPassword string
230321
)
231322
}
232323

324+
func buildDatabaseSSHArgs(target syncSSHTarget, remoteCommand string) []string {
325+
return []string{"-p", strconv.Itoa(target.Port), fmt.Sprintf("%s@%s", target.Username, target.Host), remoteCommand}
326+
}
327+
233328
func shellQuote(value string) string {
234329
escaped := strings.ReplaceAll(value, "'", "'\\''")
235330
return "'" + escaped + "'"
236331
}
237332

238-
func isSameDatabaseTarget(target ResolvedSyncTarget, config mysqlSyncConfig) bool {
239-
if !strings.EqualFold(strings.TrimSpace(target.Host), strings.TrimSpace(config.SourceHost)) {
333+
func isSameDatabaseTarget(config mysqlSyncConfig) bool {
334+
if !isSameDatabaseHost(config.SourceHost, config.TargetHost, config.DatabaseSSH.Host) {
240335
return false
241336
}
242337

243338
if config.TargetPort != config.SourcePort {
244339
return false
245340
}
246341

247-
if !strings.EqualFold(strings.TrimSpace(config.TargetHost), strings.TrimSpace(config.SourceHost)) {
342+
return strings.EqualFold(strings.TrimSpace(config.SourceDatabase), strings.TrimSpace(config.TargetDatabase))
343+
}
344+
345+
func isSameDatabaseHost(sourceHost string, targetHost string, targetSSHHost string) bool {
346+
source := strings.TrimSpace(sourceHost)
347+
target := strings.TrimSpace(targetHost)
348+
if source == "" || target == "" {
248349
return false
249350
}
250351

251-
return strings.EqualFold(strings.TrimSpace(config.SourceDatabase), strings.TrimSpace(config.TargetDatabase))
352+
if !isLoopbackHost(target) {
353+
return strings.EqualFold(source, target)
354+
}
355+
356+
return strings.EqualFold(source, strings.TrimSpace(targetSSHHost))
357+
}
358+
359+
func isLoopbackHost(host string) bool {
360+
normalized := strings.ToLower(strings.TrimSpace(host))
361+
return normalized == "localhost" || normalized == "127.0.0.1" || normalized == "::1"
252362
}

0 commit comments

Comments
 (0)