diff --git a/internal/web/actions/default/setup/detectDB.go b/internal/web/actions/default/setup/detectDB.go index aba1f4fc..2c113cbf 100644 --- a/internal/web/actions/default/setup/detectDB.go +++ b/internal/web/actions/default/setup/detectDB.go @@ -9,6 +9,7 @@ import ( "github.com/iwind/TeaGo/maps" "net" "os" + "runtime" "strings" "time" ) @@ -63,10 +64,11 @@ func (this *DetectDBAction) RunPost(params struct{}) { } this.Data["localDB"] = maps.Map{ - "host": localHost, - "port": localPort, - "username": localUsername, - "password": localPassword, + "host": localHost, + "port": localPort, + "username": localUsername, + "password": localPassword, + "canInstall": runtime.GOOS == "linux" && runtime.GOARCH == "amd64" && os.Getgid() == 0, } this.Success() diff --git a/internal/web/actions/default/setup/init.go b/internal/web/actions/default/setup/init.go index 24693662..bf91f26a 100644 --- a/internal/web/actions/default/setup/init.go +++ b/internal/web/actions/default/setup/init.go @@ -1,6 +1,9 @@ package setup -import "github.com/iwind/TeaGo" +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/setup/mysql" + "github.com/iwind/TeaGo" +) func init() { TeaGo.BeforeStart(func(server *TeaGo.Server) { @@ -15,6 +18,8 @@ func init() { Post("/status", new(StatusAction)). Post("/detectDB", new(DetectDBAction)). Post("/checkLocalIP", new(CheckLocalIPAction)). + GetPost("/mysql/installPopup", new(mysql.InstallPopupAction)). + Post("/mysql/installLogs", new(mysql.InstallLogsAction)). EndAll() }) } diff --git a/internal/web/actions/default/setup/mysql/installLogs.go b/internal/web/actions/default/setup/mysql/installLogs.go new file mode 100644 index 00000000..b13f6606 --- /dev/null +++ b/internal/web/actions/default/setup/mysql/installLogs.go @@ -0,0 +1,17 @@ +// Copyright 2023 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package mysql + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/setup/mysql/mysqlinstallers/utils" +) + +type InstallLogsAction struct { + actionutils.ParentAction +} + +func (this *InstallLogsAction) RunPost(params struct{}) { + this.Data["logs"] = utils.SharedLogger.ReadAll() + this.Success() +} diff --git a/internal/web/actions/default/setup/mysql/installPopup.go b/internal/web/actions/default/setup/mysql/installPopup.go new file mode 100644 index 00000000..19473d19 --- /dev/null +++ b/internal/web/actions/default/setup/mysql/installPopup.go @@ -0,0 +1,47 @@ +// Copyright 2023 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package mysql + +import ( + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/actionutils" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/setup/mysql/mysqlinstallers" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/setup/mysql/mysqlinstallers/utils" +) + +type InstallPopupAction struct { + actionutils.ParentAction +} + +func (this *InstallPopupAction) RunGet(params struct{}) { + this.Show() +} + +func (this *InstallPopupAction) RunPost(params struct{}) { + // 清空日志 + utils.SharedLogger.Reset() + + this.Data["isOk"] = false + + var installer = mysqlinstallers.NewMySQLInstaller() + var targetDir = "/usr/local/mysql" + xzFile, err := installer.Download() + if err != nil { + this.Data["err"] = "download failed: " + err.Error() + this.Success() + return + } + + err = installer.InstallFromFile(xzFile, targetDir) + if err != nil { + this.Data["err"] = "install from '" + xzFile + "' failed: " + err.Error() + this.Success() + return + } + + this.Data["user"] = "root" + this.Data["password"] = installer.Password() + this.Data["dir"] = targetDir + this.Data["isOk"] = true + + this.Success() +} diff --git a/internal/web/actions/default/setup/mysql/mysqlinstallers/mysql_installer.go b/internal/web/actions/default/setup/mysql/mysqlinstallers/mysql_installer.go new file mode 100644 index 00000000..35cee27d --- /dev/null +++ b/internal/web/actions/default/setup/mysql/mysqlinstallers/mysql_installer.go @@ -0,0 +1,612 @@ +// Copyright 2023 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package mysqlinstallers + +import ( + "bytes" + "crypto/rand" + "errors" + "fmt" + "github.com/TeaOSLab/EdgeAdmin/internal/web/actions/default/setup/mysql/mysqlinstallers/utils" + timeutil "github.com/iwind/TeaGo/utils/time" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +type MySQLInstaller struct { + password string +} + +func NewMySQLInstaller() *MySQLInstaller { + return &MySQLInstaller{} +} + +func (this *MySQLInstaller) InstallFromFile(xzFilePath string, targetDir string) error { + // check whether mysql already running + this.log("checking mysqld ...") + var oldPid = utils.FindPidWithName("mysqld") + if oldPid > 0 { + return errors.New("there is already a running mysql server process, pid: '" + strconv.Itoa(oldPid) + "'") + } + + // check target dir + this.log("checking target dir '" + targetDir + "' ...") + _, err := os.Stat(targetDir) + if err == nil { + // check target dir + matches, _ := filepath.Glob(targetDir + "/*") + if len(matches) > 0 { + return errors.New("target dir '" + targetDir + "' already exists and not empty") + } else { + err = os.Remove(targetDir) + if err != nil { + return errors.New("clean target dir '" + targetDir + "' failed: " + err.Error()) + } + } + } + + // check 'tar' command + this.log("checking 'tar' command ...") + var tarExe, _ = exec.LookPath("tar") + if len(tarExe) == 0 { + this.log("installing 'tar' command ...") + err = this.installTarCommand() + if err != nil { + this.log("WARN: failed to install 'tar' ...") + } + } + + // check commands + this.log("checking system commands ...") + var cmdList = []string{"tar" /** again **/, "chown", "sh"} + for _, cmd := range cmdList { + cmdPath, err := exec.LookPath(cmd) + if err != nil || len(cmdPath) == 0 { + return errors.New("could not find '" + cmd + "' command in this system") + } + } + + groupAddExe, err := this.lookupGroupAdd() + if err != nil { + return errors.New("could not find 'groupadd' command in this system") + } + + userAddExe, err := this.lookupUserAdd() + if err != nil { + return errors.New("could not find 'useradd' command in this system") + } + + // ubuntu apt + aptExe, err := exec.LookPath("apt") + if err == nil && len(aptExe) > 0 { + for _, lib := range []string{"libaio1", "libncurses5"} { + this.log("checking " + lib + " ...") + var cmd = utils.NewCmd("apt", "-y", "install", lib) + cmd.WithStderr() + err = cmd.Run() + if err != nil { + return errors.New("install " + lib + " failed: " + cmd.Stderr()) + } + time.Sleep(1 * time.Second) + } + } else { // yum + yumExe, err := exec.LookPath("yum") + if err == nil && len(yumExe) > 0 { + for _, lib := range []string{"libaio", "ncurses-libs", "ncurses-compat-libs"} { + var cmd = utils.NewCmd("yum", "-y", "install", lib) + _ = cmd.Run() + time.Sleep(1 * time.Second) + } + } + } + + // create 'mysql' user group + this.log("checking 'mysql' user group ...") + { + data, err := os.ReadFile("/etc/group") + if err != nil { + return errors.New("check user group failed: " + err.Error()) + } + if !bytes.Contains(data, []byte("\nmysql:")) { + var cmd = utils.NewCmd(groupAddExe, "mysql") + cmd.WithStderr() + err = cmd.Run() + if err != nil { + return errors.New("add 'mysql' user group failed: " + cmd.Stderr()) + } + } + } + + // create 'mysql' user + this.log("checking 'mysql' user ...") + { + data, err := os.ReadFile("/etc/passwd") + if err != nil { + return errors.New("check user failed: " + err.Error()) + } + if !bytes.Contains(data, []byte("\nmysql:")) { + var cmd *utils.Cmd + if strings.HasSuffix(userAddExe, "useradd") { + cmd = utils.NewCmd(userAddExe, "mysql", "-g", "mysql") + } else { // adduser + cmd = utils.NewCmd(userAddExe, "-S", "-G", "mysql", "mysql") + } + cmd.WithStderr() + err = cmd.Run() + if err != nil { + return errors.New("add 'mysql' user failed: " + cmd.Stderr()) + } + } + } + + // mkdir + { + var parentDir = filepath.Dir(targetDir) + stat, err := os.Stat(parentDir) + if err != nil { + if os.IsNotExist(err) { + err = os.MkdirAll(parentDir, 0777) + if err != nil { + return errors.New("try to create dir '" + parentDir + "' failed: " + err.Error()) + } + } else { + return errors.New("check dir '" + parentDir + "' failed: " + err.Error()) + } + } else { + if !stat.IsDir() { + return errors.New("'" + parentDir + "' should be a directory") + } + } + } + + // check installer file .xz + this.log("checking installer file ...") + { + stat, err := os.Stat(xzFilePath) + if err != nil { + return errors.New("could not open the installer file: " + err.Error()) + } + if stat.IsDir() { + return errors.New("'" + xzFilePath + "' not a valid file") + } + + var basename = filepath.Base(xzFilePath) + if !strings.HasSuffix(basename, ".xz") { + return errors.New("installer file should has '.xz' extension") + } + } + + // extract + this.log("extracting installer file ...") + var tmpDir = os.TempDir() + "/goedge-mysql-tmp" + { + _, err := os.Stat(tmpDir) + if err == nil { + err = os.RemoveAll(tmpDir) + if err != nil { + return errors.New("clean temporary directory '" + tmpDir + "' failed: " + err.Error()) + } + } + err = os.Mkdir(tmpDir, 0777) + if err != nil { + return errors.New("create temporary directory '" + tmpDir + "' failed: " + err.Error()) + } + } + + { + var cmd = utils.NewCmd("tar", "-xJvf", xzFilePath, "-C", tmpDir) + cmd.WithStderr() + err = cmd.Run() + if err != nil { + return errors.New("extract installer file '" + xzFilePath + "' failed: " + cmd.Stderr()) + } + } + + // create datadir + matches, err := filepath.Glob(tmpDir + "/mysql-*") + if err != nil || len(matches) == 0 { + return errors.New("could not find mysql installer directory from '" + tmpDir + "'") + } + var baseDir = matches[0] + var dataDir = baseDir + "/data" + _, err = os.Stat(dataDir) + if err != nil { + if os.IsNotExist(err) { + err = os.Mkdir(dataDir, 0777) + if err != nil { + return errors.New("create data dir '" + dataDir + "' failed: " + err.Error()) + } + } else { + return errors.New("check data dir '" + dataDir + "' failed: " + err.Error()) + } + } + + // chown datadir + { + var cmd = utils.NewCmd("chown", "mysql:mysql", dataDir) + cmd.WithStderr() + err = cmd.Run() + if err != nil { + return errors.New("chown data dir '" + dataDir + "' failed: " + err.Error()) + } + } + + // create my.cnf + var myCnfFile = "/etc/my.cnf" + _, err = os.Stat(myCnfFile) + if err == nil { + // backup it + err = os.Rename(myCnfFile, "/etc/my.cnf."+timeutil.Format("YmdHis")) + if err != nil { + return errors.New("backup '/etc/my.cnf' failed: " + err.Error()) + } + } + + // mysql server options https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html + var myCnfTemplate = this.createMyCnf(baseDir, dataDir) + err = os.WriteFile(myCnfFile, []byte(myCnfTemplate), 0666) + if err != nil { + return errors.New("write '" + myCnfFile + "' failed: " + err.Error()) + } + + // initialize + this.log("initializing mysql ...") + var generatedPassword = "" + { + var cmd = utils.NewCmd(baseDir+"/bin/mysqld", "--initialize", "--user=mysql") + cmd.WithStderr() + cmd.WithStdout() + err = cmd.Run() + if err != nil { + return errors.New("initialize failed: " + cmd.Stderr()) + } + + // read from stdout + var match = regexp.MustCompile(`temporary password is.+:\s*(.+)`).FindStringSubmatch(cmd.Stdout()) + if len(match) == 0 { + // read from stderr + match = regexp.MustCompile(`temporary password is.+:\s*(.+)`).FindStringSubmatch(cmd.Stderr()) + + if len(match) == 0 { + return errors.New("initialize successfully, but could not find generated password, please report to developer") + } + } + generatedPassword = strings.TrimSpace(match[1]) + + // write password to file + var passwordFile = baseDir + "/generated-password.txt" + err = os.WriteFile(passwordFile, []byte(generatedPassword), 0666) + if err != nil { + return errors.New("write password failed: " + err.Error()) + } + } + + // move to right place + this.log("moving files to target dir ...") + err = os.Rename(baseDir, targetDir) + if err != nil { + return errors.New("move '" + baseDir + "' to '" + targetDir + "' failed: " + err.Error()) + } + baseDir = targetDir + + // change my.cnf + myCnfTemplate = this.createMyCnf(baseDir, baseDir+"/data") + err = os.WriteFile(myCnfFile, []byte(myCnfTemplate), 0666) + if err != nil { + return errors.New("create new '" + myCnfFile + "' failed: " + err.Error()) + } + + // start mysql + this.log("starting mysql ...") + { + var cmd = utils.NewCmd(baseDir+"/bin/mysqld_safe", "--user=mysql") + cmd.WithStderr() + err = cmd.Start() + if err != nil { + return errors.New("start failed '" + cmd.String() + "': " + cmd.Stderr()) + } + + // waiting for startup + for i := 0; i < 5; i++ { + _, err = net.Dial("tcp", "127.0.0.1:3306") + if err != nil { + time.Sleep(1 * time.Second) + } else { + break + } + } + time.Sleep(1 * time.Second) + } + + // change password + newPassword, err := this.generatePassword() + if err != nil { + return errors.New("generate new password failed: " + err.Error()) + } + + this.log("changing mysql password ...") + var passwordSQL = "ALTER USER 'root'@'localhost' IDENTIFIED BY '" + newPassword + "';" + { + var cmd = utils.NewCmd("sh", "-c", baseDir+"/bin/mysql --user=root --password=\""+generatedPassword+"\" --execute=\""+passwordSQL+"\" --connect-expired-password") + cmd.WithStderr() + err = cmd.Run() + if err != nil { + return errors.New("change password failed: " + cmd.String() + ": " + cmd.Stderr()) + } + } + this.password = newPassword + var passwordFile = baseDir + "/generated-password.txt" + err = os.WriteFile(passwordFile, []byte(this.password), 0666) + if err != nil { + return errors.New("write generated file failed: " + err.Error()) + } + + // remove temporary directory + _ = os.Remove(tmpDir) + + // create link to 'mysql' client command + var clientExe = "/usr/local/bin/mysql" + _, err = os.Stat(clientExe) + if err != nil && os.IsNotExist(err) { + err = os.Symlink(baseDir+"/bin/mysql", clientExe) + if err == nil { + this.log("created symbolic link '" + clientExe + "' to '" + baseDir + "/bin/mysql'") + } else { + this.log("WARN: failed to create symbolic link '" + clientExe + "' to '" + baseDir + "/bin/mysql': " + err.Error()) + } + } + + // install service + // this is not required, so we ignore all errors + err = this.installService(baseDir) + if err != nil { + this.log("WARN: install service failed: " + err.Error()) + } + + this.log("finished") + + return nil +} + +func (this *MySQLInstaller) Download() (path string, err error) { + var client = &http.Client{} + + // check latest version + this.log("checking mysql latest version ...") + var latestVersion = "8.0.31" // 默认版本 + { + req, err := http.NewRequest(http.MethodGet, "https://dev.mysql.com/downloads/mysql/", nil) + if err != nil { + return "", err + } + + req.Header.Set("User-Agent", "curl/7.61.1") + resp, err := client.Do(req) + if err != nil { + return "", errors.New("check latest version failed: " + err.Error()) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode == http.StatusOK { + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.New("read latest version failed: " + err.Error()) + } + + var reg = regexp.MustCompile(`

MySQL Community Server ([\d.]+)

`) + var matches = reg.FindSubmatch(data) + if len(matches) > 0 { + latestVersion = string(matches[1]) + } + } + } + this.log("found version: v" + latestVersion) + + // download + this.log("start downloading ...") + var downloadURL = "https://cdn.mysql.com/Downloads/MySQL-8.0/mysql-" + latestVersion + "-linux-glibc2.17-x86_64-minimal.tar.xz" + + { + req, err := http.NewRequest(http.MethodGet, downloadURL, nil) + if err != nil { + return "", err + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36") + resp, err := client.Do(req) + if err != nil { + return "", errors.New("check latest version failed: " + err.Error()) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return "", errors.New("check latest version failed: invalid response code: " + strconv.Itoa(resp.StatusCode)) + } + + path = filepath.Base(downloadURL) + fp, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666) + if err != nil { + return "", errors.New("create download file '" + path + "' failed: " + err.Error()) + } + var writer = utils.NewProgressWriter(fp, resp.ContentLength) + var ticker = time.NewTicker(1 * time.Second) + var done = make(chan bool, 1) + go func() { + var lastProgress float32 = -1 + + for { + select { + case <-ticker.C: + var progress = writer.Progress() + if lastProgress < 0 || progress-lastProgress > 0.1 || progress == 1 { + lastProgress = progress + this.log(fmt.Sprintf("%.2f%%", progress*100)) + } + case <-done: + return + } + } + }() + _, err = io.Copy(writer, resp.Body) + if err != nil { + _ = fp.Close() + done <- true + return "", errors.New("download failed: " + err.Error()) + } + + err = fp.Close() + if err != nil { + done <- true + return "", errors.New("download failed: " + err.Error()) + } + + time.Sleep(1 * time.Second) // waiting for progress printing + done <- true + } + + return path, nil +} + +// Password get generated password +func (this *MySQLInstaller) Password() string { + return this.password +} + +// create my.cnf content +func (this *MySQLInstaller) createMyCnf(baseDir string, dataDir string) string { + return ` +[mysqld] +port=3306 +basedir="` + baseDir + `" +datadir="` + dataDir + `" + +max_connections=256 +innodb_flush_log_at_trx_commit=2 +max_prepared_stmt_count=65535 +binlog_cache_size=1M +binlog_stmt_cache_size=1M +thread_cache_size=32 +binlog_expire_logs_seconds=1209600 +` +} + +// generate random password +func (this *MySQLInstaller) generatePassword() (string, error) { + var p = make([]byte, 16) + n, err := rand.Read(p) + if err != nil { + return "", err + } + return fmt.Sprintf("%x", p[:n]), nil +} + +// print log +func (this *MySQLInstaller) log(message string) { + utils.SharedLogger.Push("[" + timeutil.Format("H:i:s") + "]" + message) +} + +// copy file +func (this *MySQLInstaller) installService(baseDir string) error { + _, err := exec.LookPath("systemctl") + if err != nil { + return err + } + + this.log("registering systemd service ...") + + var desc = `### BEGIN INIT INFO +# Provides: mysql +# Required-Start: $local_fs $network $remote_fs +# Should-Start: ypbind nscd ldap ntpd xntpd +# Required-Stop: $local_fs $network $remote_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: start and stop MySQL +# Description: MySQL is a very fast and reliable SQL database engine. +### END INIT INFO + +[Unit] +Description=MySQL Service +Before=shutdown.target +After=network-online.target + +[Service] +Type=simple +Restart=always +RestartSec=1s +ExecStart=${BASE_DIR}/support-files/mysql.server start +ExecStop=${BASE_DIR}/support-files/mysql.server stop +ExecRestart=${BASE_DIR}/support-files/mysql.server restart +ExecStatus=${BASE_DIR}/support-files/mysql.server status +ExecReload=${BASE_DIR}/support-files/mysql.server reload + +[Install] +WantedBy=multi-user.target` + + desc = strings.ReplaceAll(desc, "${BASE_DIR}", baseDir) + + err = os.WriteFile("/etc/systemd/system/mysqld.service", []byte(desc), 0666) + if err != nil { + return err + } + + var cmd = utils.NewTimeoutCmd(5*time.Second, "systemctl", "enable", "mysqld.service") + cmd.WithStderr() + err = cmd.Run() + if err != nil { + return errors.New("enable mysqld.service failed: " + cmd.Stderr()) + } + + return nil +} + +// install 'tar' command automatically +func (this *MySQLInstaller) installTarCommand() error { + // yum + yumExe, err := exec.LookPath("yum") + if err == nil && len(yumExe) > 0 { + var cmd = utils.NewTimeoutCmd(10*time.Second, yumExe, "-y", "install", "tar") + return cmd.Run() + } + + // apt + aptExe, err := exec.LookPath("apt") + if err == nil && len(aptExe) > 0 { + var cmd = utils.NewTimeoutCmd(10*time.Second, aptExe, "-y", "install", "tar") + return cmd.Run() + } + + return nil +} + +func (this *MySQLInstaller) lookupGroupAdd() (string, error) { + for _, cmd := range []string{"groupadd", "addgroup"} { + path, err := exec.LookPath(cmd) + if err == nil && len(path) > 0 { + return path, nil + } + } + return "", errors.New("not found") +} + +func (this *MySQLInstaller) lookupUserAdd() (string, error) { + for _, cmd := range []string{"useradd", "adduser"} { + path, err := exec.LookPath(cmd) + if err == nil && len(path) > 0 { + return path, nil + } + } + return "", errors.New("not found") +} diff --git a/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/cmd.go b/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/cmd.go new file mode 100644 index 00000000..b80fe895 --- /dev/null +++ b/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/cmd.go @@ -0,0 +1,162 @@ +// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package utils + +import ( + "bytes" + "context" + "os" + "os/exec" + "strings" + "time" +) + +type Cmd struct { + name string + args []string + env []string + dir string + + ctx context.Context + timeout time.Duration + cancelFunc func() + + captureStdout bool + captureStderr bool + + stdout *bytes.Buffer + stderr *bytes.Buffer + + rawCmd *exec.Cmd +} + +func NewCmd(name string, args ...string) *Cmd { + return &Cmd{ + name: name, + args: args, + } +} + +func NewTimeoutCmd(timeout time.Duration, name string, args ...string) *Cmd { + return (&Cmd{ + name: name, + args: args, + }).WithTimeout(timeout) +} + +func (this *Cmd) WithTimeout(timeout time.Duration) *Cmd { + this.timeout = timeout + + ctx, cancelFunc := context.WithTimeout(context.Background(), timeout) + this.ctx = ctx + this.cancelFunc = cancelFunc + + return this +} + +func (this *Cmd) WithStdout() *Cmd { + this.captureStdout = true + return this +} + +func (this *Cmd) WithStderr() *Cmd { + this.captureStderr = true + return this +} + +func (this *Cmd) WithEnv(env []string) *Cmd { + this.env = env + return this +} + +func (this *Cmd) WithDir(dir string) *Cmd { + this.dir = dir + return this +} + +func (this *Cmd) Start() error { + var cmd = this.compose() + return cmd.Start() +} + +func (this *Cmd) Wait() error { + var cmd = this.compose() + return cmd.Wait() +} + +func (this *Cmd) Run() error { + if this.cancelFunc != nil { + defer this.cancelFunc() + } + + var cmd = this.compose() + return cmd.Run() +} + +func (this *Cmd) RawStdout() string { + if this.stdout != nil { + return this.stdout.String() + } + return "" +} + +func (this *Cmd) Stdout() string { + return strings.TrimSpace(this.RawStdout()) +} + +func (this *Cmd) RawStderr() string { + if this.stderr != nil { + return this.stderr.String() + } + return "" +} + +func (this *Cmd) Stderr() string { + return strings.TrimSpace(this.RawStderr()) +} + +func (this *Cmd) String() string { + if this.rawCmd != nil { + return this.rawCmd.String() + } + var newCmd = exec.Command(this.name, this.args...) + return newCmd.String() +} + +func (this *Cmd) Process() *os.Process { + if this.rawCmd != nil { + return this.rawCmd.Process + } + return nil +} + +func (this *Cmd) compose() *exec.Cmd { + if this.rawCmd != nil { + return this.rawCmd + } + + if this.ctx != nil { + this.rawCmd = exec.CommandContext(this.ctx, this.name, this.args...) + } else { + this.rawCmd = exec.Command(this.name, this.args...) + } + + if this.env != nil { + this.rawCmd.Env = this.env + } + + if len(this.dir) > 0 { + this.rawCmd.Dir = this.dir + } + + if this.captureStdout { + this.stdout = &bytes.Buffer{} + this.rawCmd.Stdout = this.stdout + } + if this.captureStderr { + this.stderr = &bytes.Buffer{} + this.rawCmd.Stderr = this.stderr + } + + return this.rawCmd +} diff --git a/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/logger.go b/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/logger.go new file mode 100644 index 00000000..0b1a496f --- /dev/null +++ b/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/logger.go @@ -0,0 +1,46 @@ +// Copyright 2023 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package utils + +var SharedLogger = NewLogger() + +type Logger struct { + c chan string +} + +func NewLogger() *Logger { + return &Logger{ + c: make(chan string, 1024), + } +} + +func (this *Logger) Push(msg string) { + select { + case this.c <- msg: + default: + + } +} + +func (this *Logger) ReadAll() (msgList []string) { + msgList = []string{} + + for { + select { + case msg := <-this.c: + msgList = append(msgList, msg) + default: + return + } + } +} + +func (this *Logger) Reset() { + for { + select { + case <-this.c: + default: + return + } + } +} diff --git a/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/proc.go b/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/proc.go new file mode 100644 index 00000000..af391232 --- /dev/null +++ b/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/proc.go @@ -0,0 +1,37 @@ +// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package utils + +import ( + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + ProcDir = "/proc" +) + +func FindPidWithName(name string) int { + // process name + commFiles, err := filepath.Glob(ProcDir + "/*/comm") + if err != nil { + return 0 + } + + for _, commFile := range commFiles { + data, err := os.ReadFile(commFile) + if err != nil { + continue + } + if strings.TrimSpace(string(data)) == name { + var pieces = strings.Split(commFile, "/") + var pid = pieces[len(pieces)-2] + pidInt, _ := strconv.Atoi(pid) + return pidInt + } + } + + return 0 +} diff --git a/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/writer_progress.go b/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/writer_progress.go new file mode 100644 index 00000000..7f896bdf --- /dev/null +++ b/internal/web/actions/default/setup/mysql/mysqlinstallers/utils/writer_progress.go @@ -0,0 +1,31 @@ +// Copyright 2022 Liuxiangchao iwind.liu@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package utils + +import "io" + +type ProgressWriter struct { + rawWriter io.Writer + total int64 + written int64 +} + +func NewProgressWriter(rawWriter io.Writer, total int64) *ProgressWriter { + return &ProgressWriter{ + rawWriter: rawWriter, + total: total, + } +} + +func (this *ProgressWriter) Write(p []byte) (n int, err error) { + n, err = this.rawWriter.Write(p) + this.written += int64(n) + return +} + +func (this *ProgressWriter) Progress() float32 { + if this.total <= 0 { + return 0 + } + return float32(float64(this.written) / float64(this.total)) +} diff --git a/web/views/@default/setup/@install.css b/web/views/@default/setup/@install.css deleted file mode 100644 index b60d0616..00000000 --- a/web/views/@default/setup/@install.css +++ /dev/null @@ -1,69 +0,0 @@ -.install-box { - width: 50em; - position: fixed; - left: 50%; - margin-left: -25em; - top: 1em; - bottom: 1em; - overflow-y: auto; -} -.install-box .button.margin { - margin-top: 1em; -} -.install-box .button.primary { - float: right; -} -.install-box .button.disabled { - float: right; -} -.install-box table td.title { - width: 10em; -} -.install-box .radio { - margin-right: 1em; -} -.install-box .radio label { - cursor: pointer !important; - font-size: 0.9em !important; -} -.install-box h3 { - font-weight: normal; -} -.install-box .content-box { - overflow-y: auto; - position: fixed; - top: 5em; - bottom: 5em; - left: 50%; - width: 50em; - padding-right: 1em; - margin-left: -25em; - z-index: 1; -} -.install-box .content-box::-webkit-scrollbar { - width: 4px; -} -.install-box .button-group { - position: fixed; - left: 50%; - margin-left: -25em; - z-index: 1; - width: 50em; - bottom: 1em; -} -.install-box .button-group button { - z-index: 10; -} -.install-box .button-group .status-box { - position: absolute; - top: 3em; - left: 5em; - right: 5em; - bottom: 0; - text-align: center; - z-index: 0; -} -.install-box::-webkit-scrollbar { - width: 4px; -} -/*# sourceMappingURL=@install.css.map */ \ No newline at end of file diff --git a/web/views/@default/setup/@install.css.map b/web/views/@default/setup/@install.css.map deleted file mode 100644 index 9ec79dcb..00000000 --- a/web/views/@default/setup/@install.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["@install.less"],"names":[],"mappings":"AAAA;EAIC,WAAA;EACA,eAAA;EACA,SAAA;EACA,kBAAA;EACA,QAAA;EACA,WAAA;EACA,gBAAA;;AAVD,YAYC,QAAO;EACN,eAAA;;AAbF,YAgBC,QAAO;EACN,YAAA;;AAjBF,YAoBC,QAAO;EACN,YAAA;;AArBF,YAwBC,MACC,GAAE;EACD,WAAA;;AA1BH,YA8BC;EACC,iBAAA;;AA/BF,YA8BC,OAGC;EACC,0BAAA;EACA,gBAAA;;AAnCH,YAuCC;EACC,mBAAA;;AAxCF,YA2CC;EACC,gBAAA;EACA,eAAA;EACA,QAAA;EACA,WAAA;EACA,SAAA;EACA,WAAA;EACA,kBAAA;EACA,kBAAA;EACA,UAAA;;AApDF,YAuDC,aAAY;EACX,UAAA;;AAxDF,YA2DC;EACC,eAAA;EACA,SAAA;EACA,kBAAA;EACA,UAAA;EACA,WAAA;EACA,WAAA;;AAjEF,YA2DC,cAQC;EACC,WAAA;;AApEH,YA2DC,cAYC;EACC,kBAAA;EACA,QAAA;EACA,SAAA;EACA,UAAA;EACA,SAAA;EACA,kBAAA;EACA,UAAA;;AAKH,YAAY;EACX,UAAA","file":"@install.css"} \ No newline at end of file diff --git a/web/views/@default/setup/@install.less b/web/views/@default/setup/@install.less deleted file mode 100644 index 176224f7..00000000 --- a/web/views/@default/setup/@install.less +++ /dev/null @@ -1,86 +0,0 @@ -.install-box { - @width: 50em; - @half-width: 25em; - - width: @width; - position: fixed; - left: 50%; - margin-left: -@half-width; - top: 1em; - bottom: 1em; - overflow-y: auto; - - .button.margin { - margin-top: 1em; - } - - .button.primary { - float: right; - } - - .button.disabled { - float: right; - } - - table { - td.title { - width: 10em; - } - } - - .radio { - margin-right: 1em; - - label { - cursor: pointer !important; - font-size: 0.9em !important; - } - } - - h3 { - font-weight: normal; - } - - .content-box { - overflow-y: auto; - position: fixed; - top: 5em; - bottom: 5em; - left: 50%; - width: @width; - padding-right: 1em; - margin-left: -@half-width; - z-index: 1; - } - - .content-box::-webkit-scrollbar { - width: 4px; - } - - .button-group { - position: fixed; - left: 50%; - margin-left: -@half-width; - z-index: 1; - width: @width; - bottom: 1em; - - button { - z-index: 10; - } - - .status-box { - position: absolute; - top: 3em; - left: 5em; - right: 5em; - bottom: 0; - text-align: center; - z-index: 0; - } - } -} - -.install-box::-webkit-scrollbar { - width: 4px; -} \ No newline at end of file diff --git a/web/views/@default/setup/index.css b/web/views/@default/setup/index.css index 51126112..32221ce2 100644 --- a/web/views/@default/setup/index.css +++ b/web/views/@default/setup/index.css @@ -16,6 +16,9 @@ .install-box .button.disabled { float: right; } +.install-box table td { + vertical-align: top; +} .install-box table td.title { width: 10em; } diff --git a/web/views/@default/setup/index.css.map b/web/views/@default/setup/index.css.map index 09f3eb4e..9c3cbd3b 100644 --- a/web/views/@default/setup/index.css.map +++ b/web/views/@default/setup/index.css.map @@ -1 +1 @@ -{"version":3,"sources":["@install.less"],"names":[],"mappings":"AAAA;EAIC,WAAA;EACA,eAAA;EACA,SAAA;EACA,kBAAA;EACA,QAAA;EACA,WAAA;EACA,gBAAA;;AAVD,YAYC,QAAO;EACN,eAAA;;AAbF,YAgBC,QAAO;EACN,YAAA;;AAjBF,YAoBC,QAAO;EACN,YAAA;;AArBF,YAwBC,MACC,GAAE;EACD,WAAA;;AA1BH,YA8BC;EACC,iBAAA;;AA/BF,YA8BC,OAGC;EACC,0BAAA;EACA,gBAAA;;AAnCH,YAuCC;EACC,mBAAA;;AAxCF,YA2CC;EACC,gBAAA;EACA,eAAA;EACA,QAAA;EACA,WAAA;EACA,SAAA;EACA,WAAA;EACA,kBAAA;EACA,kBAAA;EACA,UAAA;;AApDF,YAuDC,aAAY;EACX,UAAA;;AAxDF,YA2DC;EACC,eAAA;EACA,SAAA;EACA,kBAAA;EACA,UAAA;EACA,WAAA;EACA,WAAA;;AAjEF,YA2DC,cAQC;EACC,WAAA;;AApEH,YA2DC,cAYC;EACC,kBAAA;EACA,QAAA;EACA,SAAA;EACA,UAAA;EACA,SAAA;EACA,kBAAA;EACA,UAAA;;AAKH,YAAY;EACX,UAAA","file":"index.css"} \ No newline at end of file +{"version":3,"sources":["index.less"],"names":[],"mappings":"AAAA;EAIC,WAAA;EACA,eAAA;EACA,SAAA;EACA,kBAAA;EACA,QAAA;EACA,WAAA;EACA,gBAAA;;AAVD,YAYC,QAAO;EACN,eAAA;;AAbF,YAgBC,QAAO;EACN,YAAA;;AAjBF,YAoBC,QAAO;EACN,YAAA;;AArBF,YAwBC,MACC;EACC,mBAAA;;AA1BH,YAwBC,MAKC,GAAE;EACD,WAAA;;AA9BH,YAkCC;EACC,iBAAA;;AAnCF,YAkCC,OAGC;EACC,0BAAA;EACA,gBAAA;;AAvCH,YA2CC;EACC,mBAAA;;AA5CF,YA+CC;EACC,gBAAA;EACA,eAAA;EACA,QAAA;EACA,WAAA;EACA,SAAA;EACA,WAAA;EACA,kBAAA;EACA,kBAAA;EACA,UAAA;;AAxDF,YA2DC,aAAY;EACX,UAAA;;AA5DF,YA+DC;EACC,eAAA;EACA,SAAA;EACA,kBAAA;EACA,UAAA;EACA,WAAA;EACA,WAAA;;AArEF,YA+DC,cAQC;EACC,WAAA;;AAxEH,YA+DC,cAYC;EACC,kBAAA;EACA,QAAA;EACA,SAAA;EACA,UAAA;EACA,SAAA;EACA,kBAAA;EACA,UAAA;;AAKH,YAAY;EACX,UAAA","file":"index.css"} \ No newline at end of file diff --git a/web/views/@default/setup/index.html b/web/views/@default/setup/index.html index d4159689..b047a858 100644 --- a/web/views/@default/setup/index.html +++ b/web/views/@default/setup/index.html @@ -152,6 +152,7 @@

已经自动填入从当前服务器发现的MySQL数据库信息。

当前地址不是局域网IP,可能会严重影响系统运行性能,请优先选择局域网IP。

+

如果你还没有安装MySQL,可以 尝试自动安装

diff --git a/web/views/@default/setup/index.js b/web/views/@default/setup/index.js index 84d9b7f3..97154512 100644 --- a/web/views/@default/setup/index.js +++ b/web/views/@default/setup/index.js @@ -57,7 +57,7 @@ Tea.context(function () { // 数据库 this.dbInfo = {} - this.localDB = {"host": "", "port": "", "username": "", "port": "", "isLocal": true} + this.localDB = {"host": "", "port": "", "username": "", "port": "", "isLocal": true, "canInstall": false} this.localDBHost = "" this.dbRequesting = false @@ -172,4 +172,17 @@ Tea.context(function () { }, 1000) }) } + + /** + * MySQL + */ + this.installMySQL = function () { + let that = this + teaweb.popup("/setup/mysql/installPopup", { + height: "28em", + onClose: function () { + that.detectDB() + } + }) + } }) \ No newline at end of file diff --git a/web/views/@default/setup/index.less b/web/views/@default/setup/index.less index d2f72e89..f4520bd2 100644 --- a/web/views/@default/setup/index.less +++ b/web/views/@default/setup/index.less @@ -1 +1,90 @@ -@import "@install"; \ No newline at end of file +.install-box { + @width: 50em; + @half-width: 25em; + + width: @width; + position: fixed; + left: 50%; + margin-left: -@half-width; + top: 1em; + bottom: 1em; + overflow-y: auto; + + .button.margin { + margin-top: 1em; + } + + .button.primary { + float: right; + } + + .button.disabled { + float: right; + } + + table { + td { + vertical-align: top; + } + + td.title { + width: 10em; + } + } + + .radio { + margin-right: 1em; + + label { + cursor: pointer !important; + font-size: 0.9em !important; + } + } + + h3 { + font-weight: normal; + } + + .content-box { + overflow-y: auto; + position: fixed; + top: 5em; + bottom: 5em; + left: 50%; + width: @width; + padding-right: 1em; + margin-left: -@half-width; + z-index: 1; + } + + .content-box::-webkit-scrollbar { + width: 4px; + } + + .button-group { + position: fixed; + left: 50%; + margin-left: -@half-width; + z-index: 1; + width: @width; + bottom: 1em; + + button { + z-index: 10; + } + + .status-box { + position: absolute; + top: 3em; + left: 5em; + right: 5em; + bottom: 0; + text-align: center; + z-index: 0; + } + } +} + +.install-box::-webkit-scrollbar { + width: 4px; +} \ No newline at end of file diff --git a/web/views/@default/setup/mysql/installPopup.css b/web/views/@default/setup/mysql/installPopup.css new file mode 100644 index 00000000..16c19a36 --- /dev/null +++ b/web/views/@default/setup/mysql/installPopup.css @@ -0,0 +1,32 @@ +td { + vertical-align: top; +} +td.title { + width: 10em; +} +.result-box .row { + line-height: 1.8; +} +.result-box .button-box { + margin-top: 1em; +} +.logs-box { + max-height: 12em; + overflow-y: auto; +} +.logs-box .row { + line-height: 1.8; +} +.logs-box::-webkit-scrollbar { + width: 6px; +} +h4 { + font-weight: normal !important; +} +.green { + color: #21ba45; +} +.red { + color: #db2828; +} +/*# sourceMappingURL=installPopup.css.map */ \ No newline at end of file diff --git a/web/views/@default/setup/mysql/installPopup.css.map b/web/views/@default/setup/mysql/installPopup.css.map new file mode 100644 index 00000000..6d6941c3 --- /dev/null +++ b/web/views/@default/setup/mysql/installPopup.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["installPopup.less"],"names":[],"mappings":"AAAA;EACC,mBAAA;;AAGD,EAAE;EACD,WAAA;;AAGD,WACC;EACC,gBAAA;;AAFF,WAKC;EACC,eAAA;;AAIF;EACC,gBAAA;EACA,gBAAA;;AAFD,SAIC;EACC,gBAAA;;AAIF,SAAS;EACR,UAAA;;AAGD;EACC,8BAAA;;AAGD;EACC,cAAA;;AAGD;EACC,cAAA","file":"installPopup.css"} \ No newline at end of file diff --git a/web/views/@default/setup/mysql/installPopup.html b/web/views/@default/setup/mysql/installPopup.html new file mode 100644 index 00000000..f3585d01 --- /dev/null +++ b/web/views/@default/setup/mysql/installPopup.html @@ -0,0 +1,74 @@ + + + + + + 安装GoEdge管理系统 + + + + {$TEA.VUE} + + + + + + + + +
+
+

方法1:使用命令安装MySQL

+ + + + + + + +
可以在你要安装MySQL的服务器上运行以下命令(目前仅支持Linux系统和X86架构服务器,安装后的MySQL版本是8.x.x)
sudo sh -c "$(wget https://goedge.cn/install-mysql.sh -O -)"
+ +

方法2:在本机自动安装MySQL

+ + + + + + + + + + + + + + + +
如果你想在当前管理系统所在服务器上安装MySQL,可以点击下面的按钮自动开始尝试安装,如果安装不成功,请自行安装(建议仅在小流量应用场景或测试期间使用此功能)
+ + +
安装结果 +
+
安装成功,请使用记事本或其他工具记录下面MySQL信息,防止以后忘记:
+
安装目录:{{result.dir}}
+
用户:{{result.user}}
+
密码:{{result.password}}
+
+ +
+
+
+
+ 安装失败:{{result.err}} +
+
+
请将以上错误信息报告给开发者,并改用其他方式安装MySQL。
+
+
安装过程 +
+
{{log}}
+
+
+
+ + \ No newline at end of file diff --git a/web/views/@default/setup/mysql/installPopup.js b/web/views/@default/setup/mysql/installPopup.js new file mode 100644 index 00000000..b11fb3b2 --- /dev/null +++ b/web/views/@default/setup/mysql/installPopup.js @@ -0,0 +1,65 @@ +Tea.context(function () { + this.result = { + isInstalling: false, + isInstalled: false, + isOk: false, + err: "", + + user: "", + password: "", + dir: "", + + logs: [] + } + + this.$delay(function () { + this.checkStatus() + }) + + this.install = function () { + this.result.isInstalling = true + this.result.isInstalled = false + this.result.logs = [] + + this.$post(".installPopup") + .timeout(3600) + .success(function (resp) { + this.result.isOk = resp.data.isOk + if (!resp.data.isOk) { + this.result.err = resp.data.err + } else { + this.result.user = resp.data.user + this.result.password = resp.data.password + this.result.dir = resp.data.dir + } + this.result.isInstalled = true + this.result.isInstalling = false + }) + } + + this.checkStatus = function () { + if (!this.result.isInstalling) { + this.$delay(function () { + this.checkStatus() + }, 1000) + return + } + + this.$post(".installLogs") + .success(function (resp) { + let that = this + resp.data.logs.forEach(function (log) { + that.result.logs.unshift(log) + }) + }) + .done(function () { + this.$delay(function () { + this.checkStatus() + }, 2000) + }) + } + + this.finish = function () { + teaweb.closePopup() + } +}) \ No newline at end of file diff --git a/web/views/@default/setup/mysql/installPopup.less b/web/views/@default/setup/mysql/installPopup.less new file mode 100644 index 00000000..7f9fba60 --- /dev/null +++ b/web/views/@default/setup/mysql/installPopup.less @@ -0,0 +1,42 @@ +td { + vertical-align: top; +} + +td.title { + width: 10em; +} + +.result-box { + .row { + line-height: 1.8; + } + + .button-box { + margin-top: 1em; + } +} + +.logs-box { + max-height: 12em; + overflow-y: auto; + + .row { + line-height: 1.8; + } +} + +.logs-box::-webkit-scrollbar { + width: 6px; +} + +h4 { + font-weight: normal !important; +} + +.green { + color: #21ba45; +} + +.red { + color: #db2828; +} \ No newline at end of file