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  }