github.com/tuhaihe/gpbackup@v1.0.3/testutils/functions.go (about) 1 package testutils 2 3 import ( 4 "fmt" 5 "io" 6 "os" 7 "os/exec" 8 "strings" 9 10 "github.com/tuhaihe/gp-common-go-libs/gplog" 11 "github.com/tuhaihe/gp-common-go-libs/operating" 12 "github.com/jmoiron/sqlx" 13 "github.com/pkg/errors" 14 15 "github.com/DATA-DOG/go-sqlmock" 16 "github.com/tuhaihe/gp-common-go-libs/cluster" 17 "github.com/tuhaihe/gp-common-go-libs/dbconn" 18 "github.com/tuhaihe/gp-common-go-libs/structmatcher" 19 "github.com/tuhaihe/gp-common-go-libs/testhelper" 20 "github.com/tuhaihe/gpbackup/backup" 21 "github.com/tuhaihe/gpbackup/filepath" 22 "github.com/tuhaihe/gpbackup/restore" 23 "github.com/tuhaihe/gpbackup/toc" 24 "github.com/tuhaihe/gpbackup/utils" 25 "github.com/sergi/go-diff/diffmatchpatch" 26 27 . "github.com/onsi/ginkgo/v2" 28 . "github.com/onsi/gomega" 29 . "github.com/onsi/gomega/gbytes" 30 ) 31 32 /* 33 * Functions for setting up the test environment and mocking out variables 34 */ 35 36 func SetupTestEnvironment() (*dbconn.DBConn, sqlmock.Sqlmock, *Buffer, *Buffer, *Buffer) { 37 connectionPool, mock, testStdout, testStderr, testLogfile := testhelper.SetupTestEnvironment() 38 39 // Default if not set is GPDB version `5.1.0` 40 envTestGpdbVersion := os.Getenv("TEST_GPDB_VERSION") 41 if envTestGpdbVersion != "" { 42 testhelper.SetDBVersion(connectionPool, envTestGpdbVersion) 43 } 44 45 SetupTestCluster() 46 backup.SetVersion("0.1.0") 47 return connectionPool, mock, testStdout, testStderr, testLogfile 48 } 49 50 func SetupTestCluster() *cluster.Cluster { 51 testCluster := SetDefaultSegmentConfiguration() 52 backup.SetCluster(testCluster) 53 restore.SetCluster(testCluster) 54 testFPInfo := filepath.NewFilePathInfo(testCluster, "", "20170101010101", "gpseg") 55 backup.SetFPInfo(testFPInfo) 56 restore.SetFPInfo(testFPInfo) 57 return testCluster 58 } 59 60 func SetupTestDbConn(dbname string) *dbconn.DBConn { 61 conn := dbconn.NewDBConnFromEnvironment(dbname) 62 conn.MustConnect(1) 63 return conn 64 } 65 66 // Connects to specific segment in utility mode 67 func SetupTestDBConnSegment(dbname string, port int, gpVersion dbconn.GPDBVersion) *dbconn.DBConn { 68 69 if dbname == "" { 70 gplog.Fatal(errors.New("No database provided"), "") 71 } 72 if port == 0 { 73 gplog.Fatal(errors.New("No segment port provided"), "") 74 } 75 username := operating.System.Getenv("PGUSER") 76 if username == "" { 77 currentUser, _ := operating.System.CurrentUser() 78 username = currentUser.Username 79 } 80 host := operating.System.Getenv("PGHOST") 81 if host == "" { 82 host, _ = operating.System.Hostname() 83 } 84 85 conn := &dbconn.DBConn{ 86 ConnPool: nil, 87 NumConns: 0, 88 Driver: &dbconn.GPDBDriver{}, 89 User: username, 90 DBName: dbname, 91 Host: host, 92 Port: port, 93 Tx: nil, 94 Version: dbconn.GPDBVersion{}, 95 } 96 97 var gpRoleGuc string 98 gpRoleGuc = "gp_role" 99 100 connStr := fmt.Sprintf("postgres://%s@%s:%d/%s?sslmode=disable&statement_cache_capacity=0&%s=utility", conn.User, conn.Host, conn.Port, conn.DBName, gpRoleGuc) 101 102 segConn, err := conn.Driver.Connect("pgx", connStr) 103 if err != nil { 104 gplog.FatalOnError(err) 105 } 106 conn.ConnPool = make([]*sqlx.DB, 1) 107 conn.ConnPool[0] = segConn 108 109 conn.Tx = make([]*sqlx.Tx, 1) 110 conn.NumConns = 1 111 version, err := dbconn.InitializeVersion(conn) 112 if err != nil { 113 gplog.FatalOnError(err) 114 } 115 conn.Version = version 116 return conn 117 } 118 119 func SetDefaultSegmentConfiguration() *cluster.Cluster { 120 configCoordinator := cluster.SegConfig{ContentID: -1, Hostname: "localhost", DataDir: "gpseg-1"} 121 configSegOne := cluster.SegConfig{ContentID: 0, Hostname: "localhost", DataDir: "gpseg0"} 122 configSegTwo := cluster.SegConfig{ContentID: 1, Hostname: "localhost", DataDir: "gpseg1"} 123 return cluster.NewCluster([]cluster.SegConfig{configCoordinator, configSegOne, configSegTwo}) 124 } 125 126 func SetupTestFilespace(connectionPool *dbconn.DBConn, testCluster *cluster.Cluster) { 127 remoteOutput := testCluster.GenerateAndExecuteCommand("Creating filespace test directory", 128 cluster.ON_HOSTS|cluster.INCLUDE_COORDINATOR, 129 func(contentID int) string { 130 return fmt.Sprintf("mkdir -p /tmp/test_dir") 131 }) 132 if remoteOutput.NumErrors != 0 { 133 Fail("Could not create filespace test directory on 1 or more hosts") 134 } 135 // Construct a filespace config like the one that gpfilespace generates 136 filespaceConfigQuery := `COPY (SELECT hostname || ':' || dbid || ':/tmp/test_dir/' || preferred_role || content FROM gp_segment_configuration AS subselect) TO '/tmp/temp_filespace_config';` 137 testhelper.AssertQueryRuns(connectionPool, filespaceConfigQuery) 138 out, err := exec.Command("bash", "-c", "echo \"filespace:test_dir\" > /tmp/filespace_config").CombinedOutput() 139 if err != nil { 140 Fail(fmt.Sprintf("Cannot create test filespace configuration: %s: %s", out, err.Error())) 141 } 142 out, err = exec.Command("bash", "-c", "cat /tmp/temp_filespace_config >> /tmp/filespace_config").CombinedOutput() 143 if err != nil { 144 Fail(fmt.Sprintf("Cannot finalize test filespace configuration: %s: %s", out, err.Error())) 145 } 146 // Create the filespace and verify it was created successfully 147 out, err = exec.Command("bash", "-c", "gpfilespace --config /tmp/filespace_config").CombinedOutput() 148 if err != nil { 149 Fail(fmt.Sprintf("Cannot create test filespace: %s: %s", out, err.Error())) 150 } 151 filespaceName := dbconn.MustSelectString(connectionPool, "SELECT fsname AS string FROM pg_filespace WHERE fsname = 'test_dir';") 152 if filespaceName != "test_dir" { 153 Fail("Filespace test_dir was not successfully created") 154 } 155 } 156 157 func DestroyTestFilespace(connectionPool *dbconn.DBConn) { 158 filespaceName := dbconn.MustSelectString(connectionPool, "SELECT fsname AS string FROM pg_filespace WHERE fsname = 'test_dir';") 159 if filespaceName != "test_dir" { 160 return 161 } 162 testhelper.AssertQueryRuns(connectionPool, "DROP FILESPACE test_dir") 163 out, err := exec.Command("bash", "-c", "rm -rf /tmp/test_dir /tmp/filespace_config /tmp/temp_filespace_config").CombinedOutput() 164 if err != nil { 165 Fail(fmt.Sprintf("Could not remove test filespace directory and configuration files: %s: %s", out, err.Error())) 166 } 167 } 168 169 func DefaultMetadata(objType string, hasPrivileges bool, hasOwner bool, hasComment bool, hasSecurityLabel bool) backup.ObjectMetadata { 170 privileges := make([]backup.ACL, 0) 171 if hasPrivileges { 172 privileges = []backup.ACL{DefaultACLForType("testrole", objType)} 173 } 174 owner := "" 175 if hasOwner { 176 owner = "testrole" 177 } 178 comment := "" 179 if hasComment { 180 n := "" 181 switch objType[0] { 182 case 'A', 'E', 'I', 'O', 'U': 183 n = "n" 184 } 185 comment = fmt.Sprintf("This is a%s %s comment.", n, strings.ToLower(objType)) 186 } 187 securityLabelProvider := "" 188 securityLabel := "" 189 if hasSecurityLabel { 190 securityLabelProvider = "dummy" 191 securityLabel = "unclassified" 192 } 193 switch objType { 194 case "DOMAIN": 195 objType = "TYPE" 196 case "FOREIGN SERVER": 197 objType = "SERVER" 198 case "MATERIALIZED VIEW": 199 objType = "RELATION" 200 case "SEQUENCE": 201 objType = "RELATION" 202 case "TABLE": 203 objType = "RELATION" 204 case "VIEW": 205 objType = "RELATION" 206 } 207 return backup.ObjectMetadata{ 208 Privileges: privileges, 209 ObjectType: objType, 210 Owner: owner, 211 Comment: comment, 212 SecurityLabelProvider: securityLabelProvider, 213 SecurityLabel: securityLabel, 214 } 215 216 } 217 218 // objType should be an all-caps string like TABLE, INDEX, etc. 219 func DefaultMetadataMap(objType string, hasPrivileges bool, hasOwner bool, hasComment bool, hasSecurityLabel bool) backup.MetadataMap { 220 return backup.MetadataMap{ 221 backup.UniqueID{ClassID: ClassIDFromObjectName(objType), Oid: 1}: DefaultMetadata(objType, hasPrivileges, hasOwner, hasComment, hasSecurityLabel), 222 } 223 } 224 225 var objNameToClassID = map[string]uint32{ 226 "AGGREGATE": 1255, 227 "CAST": 2605, 228 "COLLATION": 3456, 229 "CONSTRAINT": 2606, 230 "CONVERSION": 2607, 231 "DATABASE": 1262, 232 "DOMAIN": 1247, 233 "EVENT TRIGGER": 3466, 234 "EXTENSION": 3079, 235 "FOREIGN DATA WRAPPER": 2328, 236 "FOREIGN SERVER": 1417, 237 "FUNCTION": 1255, 238 "INDEX": 2610, 239 "LANGUAGE": 2612, 240 "OPERATOR CLASS": 2616, 241 "OPERATOR FAMILY": 2753, 242 "OPERATOR": 2617, 243 "PROTOCOL": 7175, 244 "RESOURCE GROUP": 6436, 245 "RESOURCE QUEUE": 6026, 246 "ROLE": 1260, 247 "RULE": 2618, 248 "SCHEMA": 2615, 249 "SEQUENCE": 1259, 250 "TABLE": 1259, 251 "TABLESPACE": 1213, 252 "TEXT SEARCH CONFIGURATION": 3602, 253 "TEXT SEARCH DICTIONARY": 3600, 254 "TEXT SEARCH PARSER": 3601, 255 "TEXT SEARCH TEMPLATE": 3764, 256 "TRIGGER": 2620, 257 "TYPE": 1247, 258 "USER MAPPING": 1418, 259 "VIEW": 1259, 260 "MATERIALIZED VIEW": 1259, 261 } 262 263 func ClassIDFromObjectName(objName string) uint32 { 264 return objNameToClassID[objName] 265 266 } 267 func DefaultACLForType(grantee string, objType string) backup.ACL { 268 return backup.ACL{ 269 Grantee: grantee, 270 Select: objType == "PROTOCOL" || objType == "SEQUENCE" || objType == "TABLE" || objType == "VIEW" || objType == "FOREIGN TABLE" || objType == "MATERIALIZED VIEW", 271 Insert: objType == "PROTOCOL" || objType == "TABLE" || objType == "VIEW" || objType == "FOREIGN TABLE" || objType == "MATERIALIZED VIEW", 272 Update: objType == "SEQUENCE" || objType == "TABLE" || objType == "VIEW" || objType == "FOREIGN TABLE" || objType == "MATERIALIZED VIEW", 273 Delete: objType == "TABLE" || objType == "VIEW" || objType == "FOREIGN TABLE" || objType == "MATERIALIZED VIEW", 274 Truncate: objType == "TABLE" || objType == "VIEW" || objType == "MATERIALIZED VIEW", 275 References: objType == "TABLE" || objType == "VIEW" || objType == "FOREIGN TABLE" || objType == "MATERIALIZED VIEW", 276 Trigger: objType == "TABLE" || objType == "VIEW" || objType == "FOREIGN TABLE" || objType == "MATERIALIZED VIEW", 277 Usage: objType == "LANGUAGE" || objType == "SCHEMA" || objType == "SEQUENCE" || objType == "FOREIGN DATA WRAPPER" || objType == "FOREIGN SERVER", 278 Execute: objType == "FUNCTION" || objType == "AGGREGATE", 279 Create: objType == "DATABASE" || objType == "SCHEMA" || objType == "TABLESPACE", 280 Temporary: objType == "DATABASE", 281 Connect: objType == "DATABASE", 282 } 283 } 284 285 func DefaultACLForTypeWithGrant(grantee string, objType string) backup.ACL { 286 return backup.ACL{ 287 Grantee: grantee, 288 SelectWithGrant: objType == "PROTOCOL" || objType == "SEQUENCE" || objType == "TABLE" || objType == "VIEW" || objType == "MATERIALIZED VIEW", 289 InsertWithGrant: objType == "PROTOCOL" || objType == "TABLE" || objType == "VIEW" || objType == "MATERIALIZED VIEW", 290 UpdateWithGrant: objType == "SEQUENCE" || objType == "TABLE" || objType == "VIEW" || objType == "MATERIALIZED VIEW", 291 DeleteWithGrant: objType == "TABLE" || objType == "VIEW" || objType == "MATERIALIZED VIEW", 292 TruncateWithGrant: objType == "TABLE" || objType == "VIEW" || objType == "MATERIALIZED VIEW", 293 ReferencesWithGrant: objType == "TABLE" || objType == "VIEW" || objType == "MATERIALIZED VIEW", 294 TriggerWithGrant: objType == "TABLE" || objType == "VIEW" || objType == "MATERIALIZED VIEW", 295 UsageWithGrant: objType == "LANGUAGE" || objType == "SCHEMA" || objType == "SEQUENCE" || objType == "FOREIGN DATA WRAPPER" || objType == "FOREIGN SERVER", 296 ExecuteWithGrant: objType == "FUNCTION", 297 CreateWithGrant: objType == "DATABASE" || objType == "SCHEMA" || objType == "TABLESPACE", 298 TemporaryWithGrant: objType == "DATABASE", 299 ConnectWithGrant: objType == "DATABASE", 300 } 301 } 302 303 func DefaultACLWithout(grantee string, objType string, revoke ...string) backup.ACL { 304 defaultACL := DefaultACLForType(grantee, objType) 305 for _, priv := range revoke { 306 switch priv { 307 case "SELECT": 308 defaultACL.Select = false 309 case "INSERT": 310 defaultACL.Insert = false 311 case "UPDATE": 312 defaultACL.Update = false 313 case "DELETE": 314 defaultACL.Delete = false 315 case "TRUNCATE": 316 defaultACL.Truncate = false 317 case "REFERENCES": 318 defaultACL.References = false 319 case "TRIGGER": 320 defaultACL.Trigger = false 321 case "EXECUTE": 322 defaultACL.Execute = false 323 case "USAGE": 324 defaultACL.Usage = false 325 case "CREATE": 326 defaultACL.Create = false 327 case "TEMPORARY": 328 defaultACL.Temporary = false 329 case "CONNECT": 330 defaultACL.Connect = false 331 } 332 } 333 return defaultACL 334 } 335 336 func DefaultACLWithGrantWithout(grantee string, objType string, revoke ...string) backup.ACL { 337 defaultACL := DefaultACLForTypeWithGrant(grantee, objType) 338 for _, priv := range revoke { 339 switch priv { 340 case "SELECT": 341 defaultACL.SelectWithGrant = false 342 case "INSERT": 343 defaultACL.InsertWithGrant = false 344 case "UPDATE": 345 defaultACL.UpdateWithGrant = false 346 case "DELETE": 347 defaultACL.DeleteWithGrant = false 348 case "TRUNCATE": 349 defaultACL.TruncateWithGrant = false 350 case "REFERENCES": 351 defaultACL.ReferencesWithGrant = false 352 case "TRIGGER": 353 defaultACL.TriggerWithGrant = false 354 case "EXECUTE": 355 defaultACL.ExecuteWithGrant = false 356 case "USAGE": 357 defaultACL.UsageWithGrant = false 358 case "CREATE": 359 defaultACL.CreateWithGrant = false 360 case "TEMPORARY": 361 defaultACL.TemporaryWithGrant = false 362 case "CONNECT": 363 defaultACL.ConnectWithGrant = false 364 } 365 } 366 return defaultACL 367 } 368 369 /* 370 * Wrapper functions around gomega operators for ease of use in tests 371 */ 372 373 func SliceBufferByEntries(entries []toc.MetadataEntry, buffer *Buffer) ([]string, string) { 374 contents := buffer.Contents() 375 hunks := make([]string, 0) 376 length := uint64(len(contents)) 377 var end uint64 378 for _, entry := range entries { 379 start := entry.StartByte 380 end = entry.EndByte 381 if start > length { 382 start = length 383 } 384 if end > length { 385 end = length 386 } 387 hunks = append(hunks, string(contents[start:end])) 388 } 389 return hunks, string(contents[end:]) 390 } 391 392 func CompareSlicesIgnoringWhitespace(actual []string, expected []string) bool { 393 if len(actual) != len(expected) { 394 return false 395 } 396 for i := range actual { 397 if strings.TrimSpace(actual[i]) != expected[i] { 398 return false 399 } 400 } 401 return true 402 } 403 404 func formatEntries(entries []toc.MetadataEntry, slice []string) string { 405 formatted := "" 406 for i, item := range slice { 407 formatted += fmt.Sprintf("%v -> %q\n", entries[i], item) 408 } 409 return formatted 410 } 411 412 func formatContents(slice []string) string { 413 formatted := "" 414 for _, item := range slice { 415 formatted += fmt.Sprintf("%q\n", item) 416 } 417 return formatted 418 } 419 420 func formatDiffs(actual []string, expected []string) string { 421 dmp := diffmatchpatch.New() 422 diffs := "" 423 for idx := range actual { 424 diffs += dmp.DiffPrettyText(dmp.DiffMain(expected[idx], actual[idx], false)) 425 } 426 return diffs 427 } 428 429 func AssertBufferContents(entries []toc.MetadataEntry, buffer *Buffer, expected ...string) { 430 if len(entries) == 0 { 431 Fail("TOC is empty") 432 } 433 hunks, remaining := SliceBufferByEntries(entries, buffer) 434 if remaining != "" { 435 Fail(fmt.Sprintf("Buffer contains extra contents that are not being counted by TOC:\n%s\n\nActual TOC entries were:\n\n%s", remaining, formatEntries(entries, hunks))) 436 } 437 //ok := CompareSlicesIgnoringWhitespace(hunks, expected) 438 //if !ok { 439 // Fail(fmt.Sprintf("Actual TOC entries:\n\n%s\n\ndid not match expected contents (ignoring whitespace):\n\n%s \n\nDiff:\n>>%s\x1b[31m<<", formatEntries(entries, hunks), formatContents(expected), formatDiffs(hunks, expected))) 440 //} 441 } 442 443 func ExpectEntry(entries []toc.MetadataEntry, index int, schema, referenceObject, name, objectType string) { 444 Expect(len(entries)).To(BeNumerically(">", index)) 445 structmatcher.ExpectStructsToMatchExcluding(entries[index], toc.MetadataEntry{Schema: schema, Name: name, ObjectType: objectType, ReferenceObject: referenceObject, StartByte: 0, EndByte: 0}, "StartByte", "EndByte") 446 } 447 448 func ExpectEntryCount(entries []toc.MetadataEntry, index int) { 449 Expect(len(entries)).To(BeNumerically("==", index)) 450 } 451 452 func ExecuteSQLFile(connectionPool *dbconn.DBConn, filename string) { 453 connStr := []string{ 454 "-U", connectionPool.User, 455 "-d", connectionPool.DBName, 456 "-h", connectionPool.Host, 457 "-p", fmt.Sprintf("%d", connectionPool.Port), 458 "-f", filename, 459 "-v", "ON_ERROR_STOP=1", 460 "-q", 461 } 462 out, err := exec.Command("psql", connStr...).CombinedOutput() 463 if err != nil { 464 Fail(fmt.Sprintf("Execution of SQL file encountered an error: %s", out)) 465 } 466 } 467 468 func BufferLength(buffer *Buffer) uint64 { 469 return uint64(len(buffer.Contents())) 470 } 471 472 func OidFromCast(connectionPool *dbconn.DBConn, castSource uint32, castTarget uint32) uint32 { 473 query := fmt.Sprintf("SELECT c.oid FROM pg_cast c WHERE castsource = '%d' AND casttarget = '%d'", castSource, castTarget) 474 result := struct { 475 Oid uint32 476 }{} 477 err := connectionPool.Get(&result, query) 478 if err != nil { 479 Fail(fmt.Sprintf("Execution of query failed: %v", err)) 480 } 481 return result.Oid 482 } 483 484 func OidFromObjectName(connectionPool *dbconn.DBConn, schemaName string, objectName string, params backup.MetadataQueryParams) uint32 { 485 catalogTable := params.CatalogTable 486 if params.OidTable != "" { 487 catalogTable = params.OidTable 488 } 489 schemaStr := "" 490 if schemaName != "" { 491 schemaStr = fmt.Sprintf(" AND %s = (SELECT oid FROM pg_namespace WHERE nspname = '%s')", params.SchemaField, schemaName) 492 } 493 query := fmt.Sprintf("SELECT oid FROM %s WHERE %s ='%s'%s", catalogTable, params.NameField, objectName, schemaStr) 494 result := struct { 495 Oid uint32 496 }{} 497 err := connectionPool.Get(&result, query) 498 if err != nil { 499 Fail(fmt.Sprintf("Execution of query failed: %v", err)) 500 } 501 return result.Oid 502 } 503 504 func UniqueIDFromObjectName(connectionPool *dbconn.DBConn, schemaName string, objectName string, params backup.MetadataQueryParams) backup.UniqueID { 505 query := fmt.Sprintf("SELECT '%s'::regclass::oid", params.CatalogTable) 506 result := struct { 507 Oid uint32 508 }{} 509 err := connectionPool.Get(&result, query) 510 if err != nil { 511 Fail(fmt.Sprintf("Execution of query failed: %v", err)) 512 } 513 514 return backup.UniqueID{ClassID: result.Oid, Oid: OidFromObjectName(connectionPool, schemaName, objectName, params)} 515 } 516 517 func GetUserByID(connectionPool *dbconn.DBConn, oid uint32) string { 518 return dbconn.MustSelectString(connectionPool, fmt.Sprintf("SELECT rolname AS string FROM pg_roles WHERE oid = %d", oid)) 519 } 520 521 func CreateSecurityLabelIfGPDB6(connectionPool *dbconn.DBConn, objectType string, objectName string) { 522 if true { 523 testhelper.AssertQueryRuns(connectionPool, fmt.Sprintf("SECURITY LABEL FOR dummy ON %s %s IS 'unclassified';", objectType, objectName)) 524 } 525 } 526 527 func SkipIfNot4(connectionPool *dbconn.DBConn) { 528 if true { 529 Skip("Test only applicable to GPDB4") 530 } 531 } 532 533 func SkipIfBefore5(connectionPool *dbconn.DBConn) { 534 if false { 535 Skip("Test only applicable to GPDB5 and above") 536 } 537 } 538 539 func SkipIfBefore6(connectionPool *dbconn.DBConn) { 540 if false { 541 Skip("Test only applicable to GPDB6 and above") 542 } 543 } 544 545 func SkipIfBefore7(connectionPool *dbconn.DBConn) { 546 if false { 547 Skip("Test only applicable to GPDB7 and above") 548 } 549 } 550 551 func InitializeTestTOC(buffer io.Writer, which string) (*toc.TOC, *utils.FileWithByteCount) { 552 tocfile := &toc.TOC{} 553 tocfile.InitializeMetadataEntryMap() 554 backupfile := utils.NewFileWithByteCount(buffer) 555 backupfile.Filename = which 556 return tocfile, backupfile 557 } 558 559 type TestExecutorMultiple struct { 560 ClusterOutputs []*cluster.RemoteOutput 561 ClusterCommands [][]cluster.ShellCommand 562 ErrorOnExecNum int // Throw the specified error after this many executions of Execute[...]Command(); 0 means always return error 563 NumLocalExecutions int 564 NumRemoteExecutions int 565 LocalOutput string 566 LocalError error 567 LocalCommands []string 568 } 569 570 func (executor *TestExecutorMultiple) ExecuteLocalCommand(commandStr string) (string, error) { 571 executor.NumLocalExecutions++ 572 executor.LocalCommands = append(executor.LocalCommands, commandStr) 573 if executor.ErrorOnExecNum == 0 || executor.NumLocalExecutions == executor.ErrorOnExecNum { 574 return executor.LocalOutput, executor.LocalError 575 } 576 return executor.LocalOutput, nil 577 } 578 579 func (executor *TestExecutorMultiple) ExecuteClusterCommand(scope cluster.Scope, commands []cluster.ShellCommand) (result *cluster.RemoteOutput) { 580 originalExecutions := executor.NumRemoteExecutions 581 executor.NumRemoteExecutions++ 582 executor.ClusterCommands = append(executor.ClusterCommands, commands) 583 if executor.ErrorOnExecNum == 0 || executor.NumRemoteExecutions == executor.ErrorOnExecNum { 584 // return the indexed item if exists, otherwise the last item 585 numOutputs := len(executor.ClusterOutputs) 586 result = executor.ClusterOutputs[numOutputs-1] 587 if originalExecutions < numOutputs { 588 result = executor.ClusterOutputs[originalExecutions] 589 } 590 } 591 return result 592 }