@@ -24,10 +24,10 @@ import (
2424
2525// -----------------------------------------------------------------------------
2626// Pure unit tests - no MySQL. These exercise the orchestration branches that
27- // run BEFORE T1's RENAME, so they do not require a real inspector.db. Per the
28- // Option A decision in commit 3's plan, the "RENAME was not attempted" check
29- // is a proxy assertion: m.inspector is nil, so if T1 were reached the test
30- // would panic instead of silently passing .
27+ // run BEFORE T1's RENAME, so they do not require a real source-primary DB. The
28+ // "RENAME was not attempted" check is a proxy assertion: m.sourcePrimaryDB is
29+ // nil, so if T1 were reached the test would fail with the source-primary-not-
30+ // initialized error instead of the earlier error it asserts on .
3131// -----------------------------------------------------------------------------
3232
3333// TestMoveTablesCutOver_NoopShortCircuits maps to the Noop semantics decision
@@ -55,8 +55,9 @@ func TestMoveTablesCutOver_NoopShortCircuits(t *testing.T) {
5555
5656// TestMoveTablesCutOver_OnBeforeCutOverHookAbortsBeforeRename maps to T0 in
5757// coop_cutover.md section 1.3 ("non-zero return code aborts cutover"). The "aborts
58- // BEFORE source DDL" assertion is enforced as a proxy: m.inspector is nil, so
59- // if T1 RENAME executed via mgtr.inspector.db, this test would panic.
58+ // BEFORE source DDL" assertion is enforced as a proxy: m.sourcePrimaryDB is nil,
59+ // so if T1 RENAME executed it would fail with the source-primary-not-initialized
60+ // error rather than the asserted hook error.
6061func TestMoveTablesCutOver_OnBeforeCutOverHookAbortsBeforeRename (t * testing.T ) {
6162 var calls []string
6263 boom := errors .New ("hook says no" )
@@ -184,6 +185,67 @@ func TestResumeMoveTablesCutOverFromCheckpointAlreadyDrained(t *testing.T) {
184185 }
185186}
186187
188+ // TestResolveSourcePrimaryConnectionConfig_AssumeMasterHostnameOverride verifies
189+ // that --assume-master-host forces the move-tables source primary to the given
190+ // host, and that --master-user/--master-password override the inherited source
191+ // credentials. No DB is required: the override branch builds the config purely
192+ // from the inspector config.
193+ func TestResolveSourcePrimaryConnectionConfig_AssumeMasterHostnameOverride (t * testing.T ) {
194+ mc := base .NewMigrationContext ()
195+ mc .InspectorConnectionConfig .User = "src_user"
196+ mc .InspectorConnectionConfig .Password = "src_pass"
197+ mc .AssumeMasterHostname = "10.0.0.5:3307"
198+ mc .CliMasterUser = "master_user"
199+ mc .CliMasterPassword = "master_pass"
200+ m := NewMigrator (mc , "test" )
201+
202+ cfg , err := m .resolveSourcePrimaryConnectionConfig ("8.0.42" )
203+ require .NoError (t , err )
204+ require .Equal (t , "10.0.0.5" , cfg .Key .Hostname )
205+ require .Equal (t , 3307 , cfg .Key .Port )
206+ require .Equal (t , "master_user" , cfg .User , "--master-user must override source credentials" )
207+ require .Equal (t , "master_pass" , cfg .Password , "--master-password must override source credentials" )
208+
209+ // Without explicit master credentials, the forced primary inherits the source
210+ // (inspector) credentials.
211+ mc .CliMasterUser = ""
212+ mc .CliMasterPassword = ""
213+ cfg , err = m .resolveSourcePrimaryConnectionConfig ("8.0.42" )
214+ require .NoError (t , err )
215+ require .Equal (t , "src_user" , cfg .User )
216+ require .Equal (t , "src_pass" , cfg .Password )
217+ }
218+
219+ // TestValidateMoveTablesSourceReadHost verifies the read-path guard: a replica
220+ // source passes, a source that resolves to the primary is blocked with a hint to
221+ // use a replica or --allow-on-source-primary, and the opt-in flag bypasses it.
222+ func TestValidateMoveTablesSourceReadHost (t * testing.T ) {
223+ newMigrator := func (srcKey , primaryKey mysql.InstanceKey , allow bool ) * Migrator {
224+ mc := base .NewMigrationContext ()
225+ mc .MoveTables .TableNames = []string {"t" }
226+ mc .InspectorConnectionConfig .Key = srcKey
227+ mc .MoveTables .SourcePrimaryConnectionConfig = & mysql.ConnectionConfig {Key : primaryKey }
228+ mc .MoveTables .AllowOnSourcePrimary = allow
229+ return NewMigrator (mc , "test" )
230+ }
231+ replica := mysql.InstanceKey {Hostname : "replica.example.com" , Port : 3306 }
232+ primary := mysql.InstanceKey {Hostname : "primary.example.com" , Port : 3306 }
233+
234+ t .Run ("source is a replica: passes" , func (t * testing.T ) {
235+ require .NoError (t , newMigrator (replica , primary , false ).validateMoveTablesSourceReadHost ())
236+ })
237+
238+ t .Run ("source is the primary: blocked" , func (t * testing.T ) {
239+ err := newMigrator (primary , primary , false ).validateMoveTablesSourceReadHost ()
240+ require .Error (t , err )
241+ require .Contains (t , err .Error (), "--allow-on-source-primary" )
242+ })
243+
244+ t .Run ("source is the primary but opted in: passes" , func (t * testing.T ) {
245+ require .NoError (t , newMigrator (primary , primary , true ).validateMoveTablesSourceReadHost ())
246+ })
247+ }
248+
187249// -----------------------------------------------------------------------------
188250// Integration tests - real MySQL via testcontainers, exercise T1/T2/T3.
189251//
@@ -251,8 +313,10 @@ func (s *MoveTablesCutOverSuite) containingDrainGTID() *mysql.GTIDBinlogCoordina
251313}
252314
253315// buildMigrator wires a Migrator with the test container's *sql.DB pinned to
254- // inspector.db and a fresh Applier. initialCoords may be nil for the drain-
255- // timeout case.
316+ // inspector.db and a fresh Applier. The cutover RENAME + drain-GTID capture run
317+ // on sourcePrimaryDB (a dedicated handle with multiStatements enabled, since
318+ // T1/T2 are issued as a single multi-statement round trip). initialCoords may be
319+ // nil for the drain-timeout case.
256320func (s * MoveTablesCutOverSuite ) buildMigrator (fakeHooks base.Hooks , initialCoords mysql.BinlogCoordinates ) (* Migrator , * base.MigrationContext ) {
257321 ctx := context .Background ()
258322 connectionConfig , err := getTestConnectionConfig (ctx , s .mysqlContainer )
@@ -261,11 +325,17 @@ func (s *MoveTablesCutOverSuite) buildMigrator(fakeHooks base.Hooks, initialCoor
261325 mc := newTestMigrationContext ()
262326 mc .ApplierConnectionConfig = connectionConfig
263327 mc .InspectorConnectionConfig = connectionConfig
328+ mc .MoveTables .SourcePrimaryConnectionConfig = connectionConfig
264329 mc .SetConnectionConfig ("innodb" )
265330 mc .Hooks = fakeHooks
266331
267332 m := NewMigrator (mc , "test" )
268333 m .inspector = & Inspector {db : s .db , migrationContext : mc }
334+ // The source primary handle needs multiStatements enabled for the consolidated
335+ // T1+T2 (RENAME; SELECT @@global.gtid_executed) round trip.
336+ sourcePrimaryDB , _ , err := mysql .GetDB (mc .Uuid , connectionConfig .GetDBUri (testMysqlDatabase )+ "&multiStatements=true" )
337+ s .Require ().NoError (err )
338+ m .sourcePrimaryDB = sourcePrimaryDB
269339 m .applier = NewApplier (mc )
270340 if initialCoords != nil {
271341 m .applier .CurrentCoordinatesMutex .Lock ()
@@ -275,6 +345,58 @@ func (s *MoveTablesCutOverSuite) buildMigrator(fakeHooks base.Hooks, initialCoor
275345 return m , mc
276346}
277347
348+ // TestDropSourceOldTableUsesSourcePrimary verifies the source `__del` rollback
349+ // handle is dropped through the dedicated source-primary connection. In
350+ // production the inspector/streamer source connections may be a read replica, so
351+ // the drop must not route through them.
352+ func (s * MoveTablesCutOverSuite ) TestDropSourceOldTableUsesSourcePrimary () {
353+ ctx := context .Background ()
354+ _ , err := s .db .ExecContext (ctx , fmt .Sprintf ("CREATE TABLE %s (id INT PRIMARY KEY)" , getTestOldTableName ()))
355+ s .Require ().NoError (err )
356+
357+ var calls []string
358+ fakeHooks := & recordingHooks {name : "fake" , calls : & calls }
359+ m , _ := s .buildMigrator (fakeHooks , s .containingDrainGTID ())
360+
361+ s .Require ().NoError (m .dropSourceOldTable ())
362+
363+ var name string
364+ err = s .db .QueryRow (fmt .Sprintf ("SHOW TABLES IN %s LIKE '_%s_del'" ,
365+ testMysqlDatabase , testMysqlTableName )).Scan (& name )
366+ s .Require ().ErrorIs (err , gosql .ErrNoRows , "source __del handle must be dropped via the source primary" )
367+ }
368+
369+ // TestResolveSourcePrimaryFallsBackToInspectorWhenNoReplica verifies the
370+ // graceful fallback: when the source --host has no upstream primary (the
371+ // standalone test container), master detection returns the inspector connection
372+ // config, so source reads and cutover writes share the one available host.
373+ func (s * MoveTablesCutOverSuite ) TestResolveSourcePrimaryFallsBackToInspectorWhenNoReplica () {
374+ var calls []string
375+ m , mc := s .buildMigrator (& recordingHooks {name : "fake" , calls : & calls }, nil )
376+
377+ cfg , err := m .resolveSourcePrimaryConnectionConfig ("8.0.42" )
378+ s .Require ().NoError (err )
379+ s .Require ().Equal (mc .InspectorConnectionConfig .Key .Hostname , cfg .Key .Hostname )
380+ s .Require ().Equal (mc .InspectorConnectionConfig .Key .Port , cfg .Key .Port )
381+ }
382+
383+ // TestAssertConnectionWritableRejectsReadOnly verifies the startup writability
384+ // gate: a writable primary passes, and a read_only server is rejected with a
385+ // clear error. The same helper guards both the source primary and the target.
386+ func (s * MoveTablesCutOverSuite ) TestAssertConnectionWritableRejectsReadOnly () {
387+ key := mysql.InstanceKey {Hostname : "test-host" , Port : 3306 }
388+ s .Require ().NoError (assertConnectionWritable (s .db , key , "source primary" ),
389+ "a writable primary must pass the gate" )
390+
391+ _ , err := s .db .Exec ("SET GLOBAL read_only = ON" )
392+ s .Require ().NoError (err )
393+ defer func () { _ , _ = s .db .Exec ("SET GLOBAL read_only = OFF" ) }()
394+
395+ err = assertConnectionWritable (s .db , key , "source primary" )
396+ s .Require ().Error (err )
397+ s .Require ().Contains (err .Error (), "read_only" )
398+ }
399+
278400// TestHappyPath drives the full T0-T6 protocol against the test container.
279401// Asserts hook ordering (T0 then T5), T4 flag set, and the source-side rename.
280402// Maps to acceptance criterion #8209 "RENAME executes; drain completes;
@@ -322,7 +444,7 @@ func (s *MoveTablesCutOverSuite) TestRenameFailurePropagates() {
322444
323445 err := m .moveTablesCutOver ()
324446 s .Require ().Error (err )
325- s .Require ().Contains (err .Error (), "RENAME failed" )
447+ s .Require ().Contains (err .Error (), "source RENAME + drain GTID capture failed" )
326448
327449 s .Require ().Equal (int64 (0 ), atomic .LoadInt64 (& mc .CutOverCompleteFlag ),
328450 "post-state: RENAME failure must leave CutOverCompleteFlag unset" )
0 commit comments