github.com/pingcap/tiup@v1.15.1/components/dm/ansible/import.go (about) 1 // Copyright 2020 PingCAP, Inc. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // See the License for the specific language governing permissions and 12 // limitations under the License. 13 14 package ansible 15 16 import ( 17 "bufio" 18 "bytes" 19 "context" 20 "fmt" 21 "os" 22 "path/filepath" 23 "strconv" 24 "strings" 25 "time" 26 27 "github.com/BurntSushi/toml" 28 "github.com/pingcap/errors" 29 "github.com/pingcap/tiup/components/dm/spec" 30 "github.com/pingcap/tiup/pkg/cluster/ansible" 31 "github.com/pingcap/tiup/pkg/cluster/ctxt" 32 "github.com/pingcap/tiup/pkg/cluster/executor" 33 "github.com/pingcap/tiup/pkg/utils" 34 "github.com/relex/aini" 35 "gopkg.in/ini.v1" 36 "gopkg.in/yaml.v2" 37 ) 38 39 // ref https://docs.ansible.com/ansible/latest/reference_appendices/config.html#the-configuration-file 40 // Changes can be made and used in a configuration file which will be searched for in the following order: 41 // 42 // ANSIBLE_CONFIG (environment variable if set) 43 // ansible.cfg (in the current directory) 44 // ~/.ansible.cfg (in the home directory) 45 // /etc/ansible/ansible.cfg 46 func searchConfigFile(dir string) (fname string, err error) { 47 // ANSIBLE_CONFIG (environment variable if set) 48 if v := os.Getenv("ANSIBLE_CONFIG"); len(v) > 0 { 49 return v, nil 50 } 51 52 // ansible.cfg (in the current directory) 53 f := filepath.Join(dir, "ansible.cfg") 54 if utils.IsExist(f) { 55 return f, nil 56 } 57 58 // ~/.ansible.cfg (in the home directory) 59 home, err := os.UserHomeDir() 60 if err != nil { 61 return "", errors.AddStack(err) 62 } 63 f = filepath.Join(home, ".ansible.cfg") 64 if utils.IsExist(f) { 65 return f, nil 66 } 67 68 // /etc/ansible/ansible.cfg 69 f = "/etc/ansible/ansible.cfg" 70 if utils.IsExist(f) { 71 return f, nil 72 } 73 74 return "", errors.Errorf("can not found ansible.cfg, dir: %s", dir) 75 } 76 77 func readConfigFile(dir string) (file *ini.File, err error) { 78 fname, err := searchConfigFile(dir) 79 if err != nil { 80 return nil, err 81 } 82 83 file, err = ini.Load(fname) 84 if err != nil { 85 return nil, errors.Annotatef(err, "failed to load ini: %s", fname) 86 } 87 88 return 89 } 90 91 func firstNonEmpty(ss ...string) string { 92 for _, s := range ss { 93 if s != "" { 94 return s 95 } 96 } 97 98 return "" 99 } 100 101 func getAbsPath(dir string, path string) string { 102 if filepath.IsAbs(path) { 103 return path 104 } 105 106 path = filepath.Join(dir, path) 107 return path 108 } 109 110 // ExecutorGetter get the executor by host. 111 type ExecutorGetter interface { 112 Get(host string) (e ctxt.Executor) 113 } 114 115 // Importer used for import from ansible. 116 // ref DM docs: https://docs.pingcap.com/zh/tidb-data-migration/dev/deploy-a-dm-cluster-using-ansible 117 type Importer struct { 118 dir string // ansible directory. 119 inventoryFileName string 120 sshType executor.SSHType 121 sshTimeout uint64 122 123 // following vars parse from ansbile 124 user string 125 sources map[string]*SourceConfig // addr(ip:port) -> SourceConfig 126 127 // only use for test. 128 // when setted, we use this executor instead of getting a truly one. 129 testExecutorGetter ExecutorGetter 130 } 131 132 // NewImporter create an Importer. 133 // @sshTimeout: set 0 to use a default value 134 func NewImporter(ansibleDir, inventoryFileName string, sshType executor.SSHType, sshTimeout uint64) (*Importer, error) { 135 dir, err := filepath.Abs(ansibleDir) 136 if err != nil { 137 return nil, errors.AddStack(err) 138 } 139 140 return &Importer{ 141 dir: dir, 142 inventoryFileName: inventoryFileName, 143 sources: make(map[string]*SourceConfig), 144 sshType: sshType, 145 sshTimeout: sshTimeout, 146 }, nil 147 } 148 149 func (im *Importer) getExecutor(host string, port int) (e ctxt.Executor, err error) { 150 if im.testExecutorGetter != nil { 151 return im.testExecutorGetter.Get(host), nil 152 } 153 154 keypath := ansible.SSHKeyPath() 155 156 cfg := executor.SSHConfig{ 157 Host: host, 158 Port: port, 159 User: im.user, 160 KeyFile: keypath, 161 Timeout: time.Second * time.Duration(im.sshTimeout), 162 } 163 164 e, err = executor.New(im.sshType, false, cfg) 165 166 return 167 } 168 169 func (im *Importer) fetchFile(ctx context.Context, host string, port int, fname string) (data []byte, err error) { 170 e, err := im.getExecutor(host, port) 171 if err != nil { 172 return nil, errors.Annotatef(err, "failed to get executor, target: %s", utils.JoinHostPort(host, port)) 173 } 174 175 tmp, err := os.MkdirTemp("", "tiup") 176 if err != nil { 177 return nil, errors.AddStack(err) 178 } 179 defer os.RemoveAll(tmp) 180 181 tmp = filepath.Join(tmp, filepath.Base(fname)) 182 183 err = e.Transfer(ctx, fname, tmp, true /*download*/, 0, false) 184 if err != nil { 185 return nil, errors.Annotatef(err, "transfer %s from %s", fname, utils.JoinHostPort(host, port)) 186 } 187 188 data, err = os.ReadFile(tmp) 189 if err != nil { 190 return nil, errors.AddStack(err) 191 } 192 193 return 194 } 195 196 func setConfig(config *map[string]any, k string, v any) { 197 if *config == nil { 198 *config = make(map[string]any) 199 } 200 201 (*config)[k] = v 202 } 203 204 // handleWorkerConfig fetch the config file of worker and generate the source 205 // which we need for the master. 206 func (im *Importer) handleWorkerConfig(ctx context.Context, srv *spec.WorkerSpec, fname string) error { 207 data, err := im.fetchFile(ctx, srv.Host, srv.SSHPort, fname) 208 if err != nil { 209 return err 210 } 211 212 config := new(Config) 213 err = toml.Unmarshal(data, config) 214 if err != nil { 215 return errors.AddStack(err) 216 } 217 218 source := config.ToSource() 219 im.sources[srv.Host+":"+strconv.Itoa(srv.Port)] = source 220 221 return nil 222 } 223 224 // ScpSourceToMaster scp the source files to master, 225 // and set V1SourcePath of the master spec. 226 func (im *Importer) ScpSourceToMaster(ctx context.Context, topo *spec.Specification) (err error) { 227 for i := 0; i < len(topo.Masters); i++ { 228 master := topo.Masters[i] 229 target := filepath.Join(firstNonEmpty(master.DeployDir, topo.GlobalOptions.DeployDir), "v1source") 230 master.V1SourcePath = target 231 232 e, err := im.getExecutor(master.Host, master.SSHPort) 233 if err != nil { 234 return errors.Annotatef(err, "failed to get executor, target: %s", utils.JoinHostPort(master.Host, master.SSHPort)) 235 } 236 _, stderr, err := e.Execute(ctx, "mkdir -p "+target, false) 237 if err != nil { 238 return errors.Annotatef(err, "failed to execute: %s", string(stderr)) 239 } 240 241 for addr, source := range im.sources { 242 f, err := os.CreateTemp("", "tiup-dm-*") 243 if err != nil { 244 return errors.AddStack(err) 245 } 246 247 data, err := yaml.Marshal(source) 248 if err != nil { 249 return errors.AddStack(err) 250 } 251 252 _, err = f.Write(data) 253 if err != nil { 254 return errors.AddStack(err) 255 } 256 257 err = e.Transfer(ctx, f.Name(), filepath.Join(target, addr+".yml"), false, 0, false) 258 if err != nil { 259 return err 260 } 261 } 262 } 263 264 return nil 265 } 266 267 func instancDeployDir(comp string, port int, hostDir string, globalDir string) string { 268 if hostDir != globalDir { 269 return filepath.Join(hostDir, fmt.Sprintf("%s-%d", comp, port)) 270 } 271 272 return "" 273 } 274 275 // ImportFromAnsibleDir generate the metadata from ansible deployed cluster. 276 // 277 //revive:disable 278 func (im *Importer) ImportFromAnsibleDir(ctx context.Context) (clusterName string, meta *spec.Metadata, err error) { 279 dir := im.dir 280 inventoryFileName := im.inventoryFileName 281 282 cfg, err := readConfigFile(dir) 283 if err != nil { 284 return "", nil, err 285 } 286 287 fname := filepath.Join(dir, inventoryFileName) 288 file, err := os.Open(fname) 289 if err != nil { 290 return "", nil, errors.AddStack(err) 291 } 292 293 inventory, err := aini.Parse(file) 294 if err != nil { 295 return "", nil, errors.AddStack(err) 296 } 297 298 meta = &spec.Metadata{ 299 Topology: new(spec.Specification), 300 } 301 topo := meta.Topology 302 303 // Grafana admin username and password 304 var grafanaUser string 305 var grafanaPass string 306 if group, ok := inventory.Groups["all"]; ok { 307 for k, v := range group.Vars { 308 switch k { 309 case "ansible_user": 310 meta.User = v 311 im.user = v 312 case "dm_version": 313 meta.Version = v 314 case "cluster_name": 315 clusterName = v 316 case "deploy_dir": 317 topo.GlobalOptions.DeployDir = v 318 // ansible convention directory for log 319 topo.GlobalOptions.LogDir = filepath.Join(v, "log") 320 case "grafana_admin_user": 321 grafanaUser = strings.Trim(v, "\"") 322 case "grafana_admin_password": 323 grafanaPass = strings.Trim(v, "\"") 324 default: 325 fmt.Println("ignore unknown global var ", k, v) 326 } 327 } 328 } 329 330 for gname, group := range inventory.Groups { 331 switch gname { 332 case "dm_master_servers": 333 for _, host := range group.Hosts { 334 srv := &spec.MasterSpec{ 335 Host: host.Vars["ansible_host"], 336 SSHPort: ansible.GetHostPort(host, cfg), 337 Imported: true, 338 } 339 340 runFileName := filepath.Join(host.Vars["deploy_dir"], "scripts", "run_dm-master.sh") 341 data, err := im.fetchFile(ctx, srv.Host, srv.SSHPort, runFileName) 342 if err != nil { 343 return "", nil, err 344 } 345 deployDir, flags, err := parseRunScript(data) 346 if err != nil { 347 return "", nil, err 348 } 349 350 if deployDir == "" { 351 return "", nil, errors.Errorf("unexpected run script %s, can get deploy dir", runFileName) 352 } 353 354 for k, v := range flags { 355 switch k { 356 case "master-addr": 357 ar := strings.Split(v, ":") 358 port, err := strconv.Atoi(ar[len(ar)-1]) 359 if err != nil { 360 return "", nil, errors.AddStack(err) 361 } 362 srv.Port = port 363 // srv.PeerPort use default value 364 case "L": 365 // in tiup, must set in Config. 366 setConfig(&srv.Config, "log-level", v) 367 case "config": 368 // Ignore the config file, nothing we care. 369 case "log-file": 370 srv.LogDir = filepath.Dir(getAbsPath(deployDir, v)) 371 default: 372 fmt.Printf("ignore unknown arg %s=%s in run script %s\n", k, v, runFileName) 373 } 374 } 375 376 srv.DeployDir = instancDeployDir(spec.ComponentDMMaster, srv.Port, host.Vars["deploy_dir"], topo.GlobalOptions.DeployDir) 377 378 topo.Masters = append(topo.Masters, srv) 379 } 380 case "dm_worker_servers": 381 for _, host := range group.Hosts { 382 srv := &spec.WorkerSpec{ 383 Host: host.Vars["ansible_host"], 384 SSHPort: ansible.GetHostPort(host, cfg), 385 DeployDir: firstNonEmpty(host.Vars["deploy_dir"], topo.GlobalOptions.DeployDir), 386 Imported: true, 387 } 388 389 runFileName := filepath.Join(host.Vars["deploy_dir"], "scripts", "run_dm-worker.sh") 390 data, err := im.fetchFile(ctx, srv.Host, srv.SSHPort, runFileName) 391 if err != nil { 392 return "", nil, err 393 } 394 deployDir, flags, err := parseRunScript(data) 395 if err != nil { 396 return "", nil, err 397 } 398 399 if deployDir == "" { 400 return "", nil, errors.Errorf("unexpected run script %s, can not get deploy directory", runFileName) 401 } 402 403 var configFileName string 404 for k, v := range flags { 405 switch k { 406 case "worker-addr": 407 ar := strings.Split(v, ":") 408 port, err := strconv.Atoi(ar[len(ar)-1]) 409 if err != nil { 410 return "", nil, errors.AddStack(err) 411 } 412 srv.Port = port 413 case "L": 414 // in tiup, must set in Config. 415 setConfig(&srv.Config, "log-level", v) 416 case "config": 417 configFileName = getAbsPath(deployDir, v) 418 case "log-file": 419 srv.LogDir = filepath.Dir(getAbsPath(deployDir, v)) 420 case "relay-dir": 421 // Safe to ignore this 422 default: 423 fmt.Printf("ignore unknown arg %s=%s in run script %s\n", k, v, runFileName) 424 } 425 } 426 427 // Deploy dir MUST always keep the same and CAN NOT change. 428 // dm-worker will save the data in the wording directory and there's no configuration 429 // to specific the directory. 430 // We will always set the wd as DeployDir. 431 srv.DeployDir = deployDir 432 433 err = im.handleWorkerConfig(ctx, srv, configFileName) 434 if err != nil { 435 return "", nil, err 436 } 437 438 topo.Workers = append(topo.Workers, srv) 439 } 440 case "dm_portal_servers": 441 fmt.Println("ignore deprecated dm_portal_servers") 442 case "prometheus_servers": 443 for _, host := range group.Hosts { 444 srv := &spec.PrometheusSpec{ 445 Host: host.Vars["ansible_host"], 446 SSHPort: ansible.GetHostPort(host, cfg), 447 DeployDir: firstNonEmpty(host.Vars["deploy_dir"], topo.GlobalOptions.DeployDir), 448 Imported: true, 449 } 450 451 runFileName := filepath.Join(host.Vars["deploy_dir"], "scripts", "run_prometheus.sh") 452 data, err := im.fetchFile(ctx, srv.Host, srv.SSHPort, runFileName) 453 if err != nil { 454 return "", nil, err 455 } 456 457 deployDir, flags, err := parseRunScript(data) 458 if err != nil { 459 return "", nil, err 460 } 461 462 if deployDir == "" { 463 return "", nil, errors.Errorf("unexpected run script %s, can get deploy dir", runFileName) 464 } 465 466 for k, v := range flags { 467 // just get data directory and port, ignore all other flags. 468 switch k { 469 case "storage.tsdb.path": 470 srv.DataDir = getAbsPath(deployDir, v) 471 case "web.listen-address": 472 ar := strings.Split(v, ":") 473 port, err := strconv.Atoi(ar[len(ar)-1]) 474 if err != nil { 475 return "", nil, errors.AddStack(err) 476 } 477 srv.Port = port 478 case "STDOUT": 479 srv.LogDir = filepath.Dir(getAbsPath(deployDir, v)) 480 case "config.file", "web.external-url", "log.level", "storage.tsdb.retention": 481 // ignore intent 482 default: 483 fmt.Printf("ignore unknown arg %s=%s in run script %s\n", k, v, runFileName) 484 } 485 } 486 487 srv.DeployDir = instancDeployDir(spec.ComponentPrometheus, srv.Port, host.Vars["deploy_dir"], topo.GlobalOptions.DeployDir) 488 489 topo.Monitors = append(topo.Monitors, srv) 490 } 491 case "alertmanager_servers": 492 for _, host := range group.Hosts { 493 srv := &spec.AlertmanagerSpec{ 494 Host: host.Vars["ansible_host"], 495 SSHPort: ansible.GetHostPort(host, cfg), 496 DeployDir: firstNonEmpty(host.Vars["deploy_dir"], topo.GlobalOptions.DeployDir), 497 Imported: true, 498 } 499 500 runFileName := filepath.Join(host.Vars["deploy_dir"], "scripts", "run_alertmanager.sh") 501 data, err := im.fetchFile(ctx, srv.Host, srv.SSHPort, runFileName) 502 if err != nil { 503 return "", nil, err 504 } 505 506 deployDir, flags, err := parseRunScript(data) 507 if err != nil { 508 return "", nil, err 509 } 510 511 if deployDir == "" { 512 return "", nil, errors.Errorf("unexpected run script %s, can get deploy dir", runFileName) 513 } 514 515 for k, v := range flags { 516 switch k { 517 case "storage.path": 518 srv.DataDir = getAbsPath(deployDir, v) 519 case "web.listen-address": 520 ar := strings.Split(v, ":") 521 port, err := strconv.Atoi(ar[len(ar)-1]) 522 if err != nil { 523 return "", nil, errors.AddStack(err) 524 } 525 srv.WebPort = port 526 case "STDOUT": 527 srv.LogDir = filepath.Dir(getAbsPath(deployDir, v)) 528 case "config.file", "data.retention", "log.level": 529 // ignore 530 default: 531 fmt.Printf("ignore unknown arg %s=%s in run script %s\n", k, v, runFileName) 532 } 533 } 534 535 srv.DeployDir = instancDeployDir(spec.ComponentAlertmanager, srv.WebPort, host.Vars["deploy_dir"], topo.GlobalOptions.DeployDir) 536 537 topo.Alertmanagers = append(topo.Alertmanagers, srv) 538 } 539 case "grafana_servers": 540 for _, host := range group.Hosts { 541 // Do not fetch the truly used config file of Grafana, 542 // get port directly from ansible ini files. 543 port := 3000 544 if v, ok := host.Vars["grafana_port"]; ok { 545 if iv, err := strconv.Atoi(v); err == nil { 546 port = iv 547 } 548 } 549 srv := &spec.GrafanaSpec{ 550 Host: host.Vars["ansible_host"], 551 SSHPort: ansible.GetHostPort(host, cfg), 552 Port: port, 553 Username: grafanaUser, 554 Password: grafanaPass, 555 Imported: true, 556 } 557 558 runFileName := filepath.Join(host.Vars["deploy_dir"], "scripts", "run_grafana.sh") 559 data, err := im.fetchFile(ctx, srv.Host, srv.SSHPort, runFileName) 560 if err != nil { 561 return "", nil, err 562 } 563 _, _, err = parseRunScript(data) 564 if err != nil { 565 return "", nil, err 566 } 567 568 srv.DeployDir = instancDeployDir(spec.ComponentGrafana, srv.Port, host.Vars["deploy_dir"], topo.GlobalOptions.DeployDir) 569 topo.Grafanas = append(topo.Grafanas, srv) 570 } 571 case "all", "ungrouped": 572 // ignore intent 573 default: 574 fmt.Println("ignore unknown group ", gname) 575 } 576 } 577 578 return 579 } 580 581 // parseRunScript parse the run script generate by dm-ansible 582 // flags contains the flags of command line, adding a key "STDOUT" 583 // if it redirect the stdout to a file. 584 func parseRunScript(data []byte) (deployDir string, flags map[string]string, err error) { 585 scanner := bufio.NewScanner(bytes.NewBuffer(data)) 586 587 flags = make(map[string]string) 588 589 for scanner.Scan() { 590 line := scanner.Text() 591 line = strings.TrimSpace(line) 592 593 // parse "DEPLOY_DIR=/home/tidb/deploy" 594 prefix := "DEPLOY_DIR=" 595 if strings.HasPrefix(line, prefix) { 596 deployDir = line[len(prefix):] 597 deployDir = strings.TrimSpace(deployDir) 598 continue 599 } 600 601 // parse such line: 602 // exec > >(tee -i -a "/home/tidb/deploy/log/alertmanager.log") 603 // 604 // get the file path, as a "STDOUT" flag. 605 if strings.Contains(line, "tee -i -a") { 606 left := strings.Index(line, "\"") 607 right := strings.LastIndex(line, "\"") 608 if left < right { 609 v := line[left+1 : right] 610 flags["STDOUT"] = v 611 } 612 } 613 614 // trim the ">> /path/to/file ..." part 615 if index := strings.Index(line, ">>"); index != -1 { 616 line = line[:index] 617 } 618 619 line = strings.TrimSuffix(line, "\\") 620 line = strings.TrimSpace(line) 621 622 // parse flag 623 if strings.HasPrefix(line, "-") { 624 seps := strings.Split(line, "=") 625 if len(seps) != 2 { 626 continue 627 } 628 629 k := strings.TrimLeft(seps[0], "-") 630 v := strings.Trim(seps[1], "\"") 631 flags[k] = v 632 } 633 } 634 635 return 636 }