diff --git a/cmd/standalone-instance-installer/.gitignore b/cmd/standalone-instance-installer/.gitignore new file mode 100644 index 00000000..07c48484 --- /dev/null +++ b/cmd/standalone-instance-installer/.gitignore @@ -0,0 +1,2 @@ +standalone-instance-installer* +prepare.sh \ No newline at end of file diff --git a/cmd/standalone-instance-installer/build.sh b/cmd/standalone-instance-installer/build.sh new file mode 100755 index 00000000..837eac5a --- /dev/null +++ b/cmd/standalone-instance-installer/build.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +OS="${1}" +ARCH="${2}" +TAG="${3}" + +if [ -z "$OS" ]; then + echo "usage: build.sh OS ARCH" + exit +fi + +if [ -z "$ARCH" ]; then + echo "usage: build.sh OS ARCH" + exit +fi + +env GOOS=linux GOARCH="${ARCH}" go build -tags="${TAG}" -trimpath -ldflags="-s -w" -o "standalone-instance-installer-${OS}-${ARCH}" main.go \ No newline at end of file diff --git a/cmd/standalone-instance-installer/main.go b/cmd/standalone-instance-installer/main.go new file mode 100644 index 00000000..1e8d5fa5 --- /dev/null +++ b/cmd/standalone-instance-installer/main.go @@ -0,0 +1,74 @@ +// Copyright 2024 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package main + +import ( + "fmt" + "github.com/TeaOSLab/EdgeAPI/internal/instances" + _ "github.com/iwind/TeaGo/bootstrap" + "github.com/iwind/TeaGo/lists" + "os" + "strings" +) + +func main() { + dbPasswordData, err := os.ReadFile("/usr/local/mysql/generated-password.txt") + if err != nil { + fmt.Println("[ERROR]read mysql password failed: " + err.Error()) + return + } + var dbPassword = strings.TrimSpace(string(dbPasswordData)) + + var isTesting = lists.ContainsString(os.Args, "-test") || lists.ContainsString(os.Args, "--test") + if isTesting { + fmt.Println("testing mode ...") + } + + var instance = instances.NewInstance(instances.Options{ + IsTesting: isTesting, + Verbose: lists.ContainsString(os.Args, "-v"), + Cacheable: true, + WorkDir: "", + SrcDir: "/usr/local/goedge/src", + DB: struct { + Host string + Port int + Username string + Password string + Name string + }{ + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Password: dbPassword, + Name: "edges", + }, + AdminNode: struct { + Port int + }{ + Port: 7788, + }, + APINode: struct { + HTTPPort int + RestHTTPPort int + }{ + HTTPPort: 8001, + RestHTTPPort: 8002, + }, + Node: struct{ HTTPPort int }{ + HTTPPort: 8080, + }, + UserNode: struct { + HTTPPort int + }{ + HTTPPort: 7799, + }, + }) + err = instance.SetupAll() + if err != nil { + fmt.Println("[ERROR]setup failed: " + err.Error()) + return + } + + fmt.Println("ok") +} diff --git a/internal/db/models/metric_stat_dao.go b/internal/db/models/metric_stat_dao.go index 3e52f050..69089223 100644 --- a/internal/db/models/metric_stat_dao.go +++ b/internal/db/models/metric_stat_dao.go @@ -5,7 +5,6 @@ import ( "github.com/TeaOSLab/EdgeAPI/internal/goman" "github.com/TeaOSLab/EdgeAPI/internal/remotelogs" "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs" - "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql" "github.com/iwind/TeaGo/Tea" "github.com/iwind/TeaGo/dbs" @@ -759,10 +758,5 @@ func (this *MetricStatDAO) canIgnore(err error) bool { } // 忽略 Error 1213: Deadlock found 错误 - mysqlErr, ok := err.(*mysql.MySQLError) - if ok && mysqlErr.Number == 1213 { - return true - } - - return false + return CheckSQLErrCode(err, 1213) } diff --git a/internal/db/models/metric_sum_stat_dao.go b/internal/db/models/metric_sum_stat_dao.go index 47730b83..9f2a37a5 100644 --- a/internal/db/models/metric_sum_stat_dao.go +++ b/internal/db/models/metric_sum_stat_dao.go @@ -4,7 +4,6 @@ import ( "github.com/TeaOSLab/EdgeAPI/internal/goman" "github.com/TeaOSLab/EdgeAPI/internal/remotelogs" "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs" - "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql" "github.com/iwind/TeaGo/Tea" "github.com/iwind/TeaGo/dbs" @@ -289,10 +288,5 @@ func (this *MetricSumStatDAO) canIgnore(err error) bool { } // 忽略 Error 1213: Deadlock found 错误 - mysqlErr, ok := err.(*mysql.MySQLError) - if ok && mysqlErr.Number == 1213 { - return true - } - - return false + return CheckSQLErrCode(err, 1213) } diff --git a/internal/db/models/utils.go b/internal/db/models/utils.go index a33ac3b7..809e6d7b 100644 --- a/internal/db/models/utils.go +++ b/internal/db/models/utils.go @@ -1,6 +1,7 @@ package models import ( + "errors" "github.com/go-sql-driver/mysql" "github.com/iwind/TeaGo/dbs" "github.com/iwind/TeaGo/types" @@ -60,8 +61,8 @@ func CheckSQLErrCode(err error, code uint16) bool { } // 快速判断错误方法 - mysqlErr, ok := err.(*mysql.MySQLError) - if ok && mysqlErr.Number == code { // Error 1050: Table 'xxx' already exists + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == code { // Error 1050: Table 'xxx' already exists return true } @@ -86,6 +87,6 @@ func IsMySQLError(err error) bool { if err == nil { return false } - _, ok := err.(*mysql.MySQLError) - return ok + var mysqlErr *mysql.MySQLError + return errors.As(err, &mysqlErr) } diff --git a/internal/db/models/utils_test.go b/internal/db/models/utils_test.go new file mode 100644 index 00000000..a7ea82dc --- /dev/null +++ b/internal/db/models/utils_test.go @@ -0,0 +1,39 @@ +// Copyright 2024 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package models_test + +import ( + "errors" + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/iwind/TeaGo/assert" + "github.com/iwind/TeaGo/dbs" + "testing" +) + +func TestIsMySQLError(t *testing.T) { + var a = assert.NewAssertion(t) + + { + var err error + a.IsFalse(models.IsMySQLError(err)) + } + + { + var err = errors.New("hello") + a.IsFalse(models.IsMySQLError(err)) + } + + { + db, err := dbs.Default() + if err != nil { + t.Fatal(err) + } + defer func() { + _ = db.Close() + }() + _, err = db.Exec("SELECT abc") + a.IsTrue(models.IsMySQLError(err)) + a.IsTrue(models.CheckSQLErrCode(err, 1054)) + a.IsFalse(models.CheckSQLErrCode(err, 1000)) + } +} diff --git a/internal/instances/README.md b/internal/instances/README.md new file mode 100644 index 00000000..38aa44e4 --- /dev/null +++ b/internal/instances/README.md @@ -0,0 +1,39 @@ +# 实例 + +## 目录结构: +~~~ +/opt/cache +/usr/local/goedge/ + edge-admin/ + configs/ + api_admin.yaml + api_db.yaml + server.yaml + edge-api/ + configs/api.yaml + configs/db.yaml + api-node/ + configs/api_node.yaml + api-user/ + configs/api_user.yaml + src/ +/usr/bin/ + edge-admin -> ... + edge-api -> ... + edge-node -> ... + edge-user -> ... +/usr/local/mysql +~~~ + +* 其中 `->` 表示软链接。 +* `src/` 目录下放置zip格式的待安装压缩包 + +## 端口 +* Admin:7788 +* API:8001 +* API HTTP:8002 +* User: 7799 +* Server: 8080 + +## 数据库 +数据库名称为 `edges` \ No newline at end of file diff --git a/internal/instances/api_config.go b/internal/instances/api_config.go new file mode 100644 index 00000000..1eedb601 --- /dev/null +++ b/internal/instances/api_config.go @@ -0,0 +1,16 @@ +// Copyright 2024 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package instances + +import "gopkg.in/yaml.v3" + +type APIConfig struct { + RPCEndpoints []string `yaml:"rpc.endpoints,flow,omitempty" json:"rpc.endpoints"` + RPCDisableUpdate bool `yaml:"rpc.disableUpdate,omitempty" json:"rpc.disableUpdate"` + NodeId string `yaml:"nodeId" json:"nodeId"` + Secret string `yaml:"secret" json:"secret"` +} + +func (this *APIConfig) AsYAML() ([]byte, error) { + return yaml.Marshal(this) +} diff --git a/internal/instances/instance.go b/internal/instances/instance.go new file mode 100644 index 00000000..1012cb7e --- /dev/null +++ b/internal/instances/instance.go @@ -0,0 +1,1009 @@ +// Copyright 2024 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package instances + +import ( + "encoding/json" + "errors" + "fmt" + teaconst "github.com/TeaOSLab/EdgeAPI/internal/const" + "github.com/TeaOSLab/EdgeAPI/internal/db/models" + "github.com/TeaOSLab/EdgeAPI/internal/installers/helpers" + "github.com/TeaOSLab/EdgeAPI/internal/setup" + executils "github.com/TeaOSLab/EdgeAPI/internal/utils/exec" + "github.com/TeaOSLab/EdgeCommon/pkg/nodeconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs" + "github.com/TeaOSLab/EdgeCommon/pkg/serverconfigs/firewallconfigs" + "github.com/iwind/TeaGo" + "github.com/iwind/TeaGo/Tea" + "github.com/iwind/TeaGo/dbs" + "github.com/iwind/TeaGo/lists" + "github.com/iwind/TeaGo/rands" + "github.com/iwind/TeaGo/types" + "gopkg.in/yaml.v3" + "log" + "net" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" +) + +type Instance struct { + options Options +} + +func NewInstance(options Options) *Instance { + return &Instance{ + options: options, + } +} + +func (this *Instance) SetupAll() error { + type TaskFn func() error + for _, taskFn := range []TaskFn{ + this.SetupDB, + this.SetupAdminNode, + this.SetupAPINode, + this.SetupNode, + this.SetupUserNode, + this.Clean, + this.Startup, + } { + err := taskFn() + if err != nil { + return err + } + } + return nil +} + +func (this *Instance) SetupDB() error { + if this.options.Verbose { + log.Println("setup db ...") + } + + // 检查数据库名 + { + db, dbErr := this.dbInstanceWithoutDBName() + if dbErr != nil { + return dbErr + } + + defer func() { + _ = db.Close() + }() + + _, err := db.Exec("USE `" + this.options.DB.Name + "`") + if err != nil { + if models.CheckSQLErrCode(err, 1049) { + _, err = db.Exec("CREATE DATABASE `" + this.options.DB.Name + "` DEFAULT CHARSET utf8mb4") + if err != nil { + return fmt.Errorf("create new database failed: %w", err) + } + } else { + return fmt.Errorf("check database failed: %w", err) + } + } + } + + // 检查版本 + { + db, instanceErr := this.dbInstance(false) + if instanceErr != nil { + return instanceErr + } + + defer func() { + _ = db.Close() + }() + + dbConfig, configErr := db.Config() + if configErr != nil { + return configErr + } + + var shouldExecute bool + version, err := db.FindCol(0, "SELECT version FROM edgeVersions") + if err != nil { + shouldExecute = true + } else { + if !strings.HasPrefix(types.String(version), teaconst.Version+".") { + shouldExecute = true + } + } + if shouldExecute { + var executor = setup.NewSQLExecutor(dbConfig) + err = executor.Run(false) + if err != nil { + return fmt.Errorf("execute sql failed: %w", err) + } + + // wait to commit + time.Sleep(1 * time.Second) + } + } + + // 启用数据库 + db, instanceErr := this.dbInstance(true) + if instanceErr != nil { + return instanceErr + } + + defer func() { + _ = db.Close() + }() + + // 创建Admin Token + var tx *dbs.Tx + { + one, err := db.FindOne("SELECT * FROM edgeAPITokens WHERE role='admin'") + if err != nil { + return err + } + if len(one) == 0 { + var nodeId = rands.HexString(32) + var secret = rands.String(32) + err = models.SharedApiTokenDAO.CreateAPIToken(tx, nodeId, secret, "admin") + if err != nil { + return fmt.Errorf("create admin node token failed: %w", err) + } + } + } + + // 创建Admin + { + one, err := db.FindOne("SELECT * FROM edgeAdmins") + if err != nil { + return err + } + if len(one) == 0 { + var password = rands.String(16) + // 保存密码到文件 + var dir = this.options.WorkDir + "/usr/local/goedge" + _, err = os.Stat(dir) + if err != nil { + err = os.MkdirAll(dir, 0777) + if err != nil { + return fmt.Errorf("create directory '"+dir+"' failed: %w", err) + } + } + err = os.WriteFile(dir+"/password.txt", []byte("Admin Password: "+password+"\n"), 0666) + if err != nil { + return fmt.Errorf("write 'password.txt' failed: %w", err) + } + + _, err = models.SharedAdminDAO.CreateAdmin(tx, "admin", true, password, "Admin", true, []byte("[]")) + if err != nil { + return fmt.Errorf("create admin failed: %w", err) + } + } + } + + // 创建API Node + { + one, findErr := db.FindOne("SELECT * FROM edgeAPINodes") + if findErr != nil { + return findErr + } + if len(one) == 0 { + // http + var httpConfig = &serverconfigs.HTTPProtocolConfig{} + httpConfig.IsOn = true + httpConfig.Listen = []*serverconfigs.NetworkAddressConfig{ + { + Protocol: "http", + PortRange: types.String(this.options.APINode.HTTPPort), + }, + } + httpJSON, encodeErr := json.Marshal(httpConfig) + if encodeErr != nil { + return fmt.Errorf("encode api http config failed: %w", encodeErr) + } + + // rest + var restHTTPConfig = &serverconfigs.HTTPProtocolConfig{} + restHTTPConfig.IsOn = true + restHTTPConfig.Listen = []*serverconfigs.NetworkAddressConfig{ + { + Protocol: "http", + PortRange: types.String(this.options.APINode.RestHTTPPort), + }, + } + restHTTPJSON, encodeErr := json.Marshal(restHTTPConfig) + if encodeErr != nil { + return fmt.Errorf("encode api rest http config failed: %w", encodeErr) + } + + // access addrs + var accessAddrs = []*serverconfigs.NetworkAddressConfig{ + { + Protocol: "http", + Host: "127.0.0.1", + PortRange: types.String(this.options.APINode.HTTPPort), + }, + } + accessAddrsJSON, encodeErr := json.Marshal(accessAddrs) + if encodeErr != nil { + return fmt.Errorf("encode access addresses failed: %w", encodeErr) + } + + _, err := models.SharedAPINodeDAO.CreateAPINode(tx, "API Node", "Primary API Node", httpJSON, nil, true, restHTTPJSON, nil, accessAddrsJSON, true) + if err != nil { + return err + } + } + { + // check token + nodes, nodesErr := models.SharedAPINodeDAO.FindAllEnabledAPINodes(tx) + if nodesErr != nil { + return nodesErr + } + for _, node := range nodes { + token, err := models.SharedApiTokenDAO.FindEnabledTokenWithNode(tx, node.UniqueId) + if err != nil { + return err + } + if token == nil { + err = models.SharedApiTokenDAO.CreateAPIToken(tx, node.UniqueId, node.Secret, nodeconfigs.NodeRoleAPI) + if err != nil { + return err + } + } + } + } + } + + // 创建Node + var clusterId int64 + { + { + // check cluster + clusterIdCol, err := db.FindCol(0, "SELECT id FROM edgeNodeClusters WHERE state=1") + if err != nil { + return err + } + clusterId = types.Int64(clusterIdCol) + if clusterId == 0 { + return errors.New("invalid cluster id '" + types.String(clusterId) + "'") + } + } + + one, findErr := db.FindOne("SELECT * FROM edgeNodes") + if findErr != nil { + return findErr + } + if len(one) == 0 { + _, err := models.SharedNodeDAO.CreateNode(tx, 0, "Local Node", clusterId, 0, 0) + if err != nil { + return fmt.Errorf("create node failed: %w", err) + } + } + } + + // 检查User + var userId int64 + { + one, findErr := db.FindOne("SELECT id FROM edgeUsers WHERE state=1") + if findErr != nil { + return findErr + } + if len(one) == 0 { + return errors.New("user must not be created yet") + } + userId = one.GetInt64("id") + } + + // 创建用户节点 + { + one, findErr := db.FindOne("SELECT * FROM edgeUserNodes") + if findErr != nil { + return findErr + } + if len(one) == 0 { + // http + var httpConfig = &serverconfigs.HTTPProtocolConfig{} + httpConfig.IsOn = true + httpConfig.Listen = []*serverconfigs.NetworkAddressConfig{ + { + Protocol: "http", + PortRange: types.String(this.options.UserNode.HTTPPort), + }, + } + httpJSON, encodeErr := json.Marshal(httpConfig) + if encodeErr != nil { + return fmt.Errorf("encode api http config failed: %w", encodeErr) + } + + // access addrs + var accessAddrs = []*serverconfigs.NetworkAddressConfig{ + { + Protocol: "http", + Host: "127.0.0.1", + PortRange: types.String(this.options.UserNode.HTTPPort), + }, + } + accessAddrsJSON, encodeErr := json.Marshal(accessAddrs) + if encodeErr != nil { + return fmt.Errorf("encode access addresses failed: %w", encodeErr) + } + + _, err := models.SharedUserNodeDAO.CreateUserNode(tx, "User Platform", "Created by system", httpJSON, nil, accessAddrsJSON, true) + if err != nil { + return err + } + } + } + + // 创建网站 + { + one, findErr := db.FindOne("SELECT id FROM edgeServers WHERE state=1") + if findErr != nil { + return findErr + } + if len(one) == 0 { + + var webId int64 + + { + { + var err error + webId, err = models.SharedHTTPWebDAO.CreateWeb(tx, 0, userId, nil) + if err != nil { + return fmt.Errorf("create web failed: %w", err) + } + } + + // 访问日志 + { + err := models.SharedHTTPWebDAO.UpdateWebAccessLogConfig(tx, webId, []byte(`{ + "isPrior": false, + "isOn": true, + "fields": [1, 2, 6, 7], + "status1": true, + "status2": true, + "status3": true, + "status4": true, + "status5": true, + + "storageOnly": false, + "storagePolicies": [], + + "firewallOnly": false + }`)) + if err != nil { + return err + } + } + + // websocket + { + websocketId, err := models.SharedHTTPWebsocketDAO.CreateWebsocket(tx, []byte(`{ + "count": 30, + "unit": "second" + }`), true, nil, true, "") + if err != nil { + return err + } + + err = models.SharedHTTPWebDAO.UpdateWebsocket(tx, webId, []byte(`{ + "isPrior": false, + "isOn": true, + "websocketId": `+types.String(websocketId)+` + }`)) + if err != nil { + return err + } + } + + // cache + { + var cacheConfig = &serverconfigs.HTTPCacheConfig{ + IsPrior: false, + IsOn: true, + AddStatusHeader: true, + PurgeIsOn: false, + PurgeKey: "", + CacheRefs: []*serverconfigs.HTTPCacheRef{}, + } + cacheConfigJSON, err := json.Marshal(cacheConfig) + if err != nil { + return err + } + err = models.SharedHTTPWebDAO.UpdateWebCache(tx, webId, cacheConfigJSON) + if err != nil { + return err + } + } + + // waf + { + var firewallRef = &firewallconfigs.HTTPFirewallRef{ + IsPrior: false, + IsOn: true, + FirewallPolicyId: 0, + } + firewallRefJSON, err := json.Marshal(firewallRef) + if err != nil { + return err + } + err = models.SharedHTTPWebDAO.UpdateWebFirewall(tx, webId, firewallRefJSON) + if err != nil { + return err + } + } + + // stat + { + var statConfig = &serverconfigs.HTTPStatRef{ + IsPrior: false, + IsOn: true, + } + statJSON, err := json.Marshal(statConfig) + if err != nil { + return err + } + err = models.SharedHTTPWebDAO.UpdateWebStat(tx, webId, statJSON) + if err != nil { + return err + } + } + } + + var httpConfig = &serverconfigs.HTTPProtocolConfig{} + httpConfig.IsOn = true + httpConfig.Listen = []*serverconfigs.NetworkAddressConfig{ + { + Protocol: "http", + PortRange: types.String(this.options.Node.HTTPPort), + }, + } + httpConfigJSON, err := json.Marshal(httpConfig) + if err != nil { + return err + } + + // reverse proxy + reverseProxyId, err := models.SharedReverseProxyDAO.CreateReverseProxy(tx, 0, userId, nil, nil, nil) + if err != nil { + return err + } + var reverseProxyRef = &serverconfigs.ReverseProxyRef{ + IsOn: true, + ReverseProxyId: reverseProxyId, + } + reverseProxyRefJSON, err := json.Marshal(reverseProxyRef) + if err != nil { + return err + } + + _, err = models.SharedServerDAO.CreateServer(tx, 0, userId, serverconfigs.ServerTypeHTTPProxy, "First Site", "Created by system", []byte(`[{"name": "example.org", "type":"full"}]`), false, nil, httpConfigJSON, nil, nil, nil, nil, nil, webId, reverseProxyRefJSON, clusterId, nil, nil, nil, 0) + if err != nil { + return fmt.Errorf("create server failed: %w", err) + } + } + } + + // 删除任务 + { + err := models.SharedNodeTaskDAO.DeleteAllNodeTasks(tx) + if err != nil { + return err + } + } + + return nil +} + +func (this *Instance) SetupAdminNode() error { + if this.options.Verbose { + log.Println("setup admin node ...") + } + + { + err := this.unzip("admin", teaconst.Version) + if err != nil { + return err + } + } + + // create api_node.yaml + db, findErr := this.dbInstance(true) + if findErr != nil { + return findErr + } + + var apiYAMLData []byte + { + node, err := db.FindOne("SELECT nodeId,secret FROM edgeAPITokens WHERE state=1 AND role='admin'") + if err != nil { + return err + } + if node == nil { + return errors.New("can not find admin api token in database") + } + + var apiConfig = &APIConfig{ + RPCEndpoints: []string{"http://127.0.0.1:" + types.String(this.options.APINode.HTTPPort)}, + RPCDisableUpdate: true, + NodeId: node.GetString("nodeId"), + Secret: node.GetString("secret"), + } + data, err := apiConfig.AsYAML() + if err != nil { + return err + } + apiYAMLData = data + err = os.WriteFile(this.targetPrefix()+"/edge-admin/configs/api_admin.yaml", data, 0666) + if err != nil { + return err + } + } + + // backup + // ignore errors + { + homeDir, err := os.UserHomeDir() + if err == nil { + var dir = homeDir + "/.edge-admin/" + _, err = os.Stat(dir) + if err != nil { + _ = os.MkdirAll(dir, 0777) + } + _ = os.WriteFile(dir+"/api_admin.yaml", apiYAMLData, 0666) + } + } + + // create database config + { + dbConfig, err := db.Config() + if err != nil { + return fmt.Errorf("retrieve database config failed: %w", err) + } + var fullConfig = dbs.Config{} + fullConfig.Default.DB = "prod" + fullConfig.DBs = map[string]*dbs.DBConfig{ + "prod": dbConfig, + } + + data, err := yaml.Marshal(fullConfig) + if err != nil { + return err + } + err = os.WriteFile(this.targetPrefix()+"/edge-admin/configs/api_db.yaml", data, 0666) + if err != nil { + return err + } + } + + // server.yaml + { + var serverConfig = &TeaGo.ServerConfig{} + serverConfig.Env = "prod" + serverConfig.Http.On = true + serverConfig.Http.Listen = []string{":" + types.String(this.options.AdminNode.Port)} + data, err := yaml.Marshal(serverConfig) + if err != nil { + return err + } + err = os.WriteFile(this.targetPrefix()+"/edge-admin/configs/server.yaml", data, 0666) + if err != nil { + return err + } + } + + return nil +} + +func (this *Instance) SetupAPINode() error { + if this.options.Verbose { + log.Println("setup api node ...") + } + + { + err := this.unzip("api", teaconst.NodeVersion) + if err != nil { + return err + } + } + + // create api_node.yaml + db, findErr := this.dbInstance(true) + if findErr != nil { + return findErr + } + + { + node, err := db.FindOne("SELECT uniqueId,secret FROM edgeAPINodes WHERE state=1") + if err != nil { + return err + } + if node == nil { + return errors.New("can not find node in database") + } + + var apiConfig = &APIConfig{ + NodeId: node.GetString("uniqueId"), + Secret: node.GetString("secret"), + } + data, err := apiConfig.AsYAML() + if err != nil { + return err + } + err = os.WriteFile(this.targetPrefix()+"/edge-api/configs/api.yaml", data, 0666) + if err != nil { + return err + } + } + + // create database config + { + dbConfig, err := db.Config() + if err != nil { + return fmt.Errorf("retrieve database config failed: %w", err) + } + var fullConfig = dbs.Config{} + fullConfig.Default.DB = "prod" + fullConfig.DBs = map[string]*dbs.DBConfig{ + "prod": dbConfig, + } + + data, err := yaml.Marshal(fullConfig) + if err != nil { + return err + } + err = os.WriteFile(this.targetPrefix()+"/edge-api/configs/db.yaml", data, 0666) + if err != nil { + return err + } + } + + return nil +} + +func (this *Instance) SetupNode() error { + if this.options.Verbose { + log.Println("setup node ...") + } + + { + err := this.unzip("node", teaconst.NodeVersion) + if err != nil { + return err + } + } + + // create api_node.yaml + { + db, err := this.dbInstance(true) + if err != nil { + return err + } + node, err := db.FindOne("SELECT uniqueId,secret FROM edgeNodes WHERE state=1") + if err != nil { + return err + } + if node == nil { + return errors.New("can not find node in database") + } + + var apiConfig = &APIConfig{ + RPCEndpoints: []string{"http://127.0.0.1:" + types.String(this.options.APINode.HTTPPort)}, + RPCDisableUpdate: true, + NodeId: node.GetString("uniqueId"), + Secret: node.GetString("secret"), + } + data, err := apiConfig.AsYAML() + if err != nil { + return err + } + err = os.WriteFile(this.targetPrefix()+"/edge-node/configs/api_node.yaml", data, 0666) + if err != nil { + return err + } + } + + return nil +} + +func (this *Instance) SetupUserNode() error { + if !this.isPlus() { + return nil + } + + if this.options.Verbose { + log.Println("setup user node ...") + } + + { + err := this.unzip("user", teaconst.NodeVersion) + if err != nil { + return err + } + } + + // create api_node.yaml + { + db, err := this.dbInstance(true) + if err != nil { + return err + } + node, err := db.FindOne("SELECT uniqueId,secret FROM edgeUserNodes WHERE state=1") + if err != nil { + return err + } + if node == nil { + return errors.New("can not find node in database") + } + + var apiConfig = &APIConfig{ + RPCEndpoints: []string{"http://127.0.0.1:" + types.String(this.options.APINode.HTTPPort)}, + RPCDisableUpdate: true, + NodeId: node.GetString("uniqueId"), + Secret: node.GetString("secret"), + } + data, err := apiConfig.AsYAML() + if err != nil { + return err + } + err = os.WriteFile(this.targetPrefix()+"/edge-user/configs/api_user.yaml", data, 0666) + if err != nil { + return err + } + } + + return nil +} + +func (this *Instance) Clean() error { + if this.options.Verbose { + log.Println("cleaning ...") + } + + { + var dir = this.targetPrefix() + "/edge-admin/edge-api" + _, err := os.Stat(dir) + if err == nil { + err = os.RemoveAll(dir) + if err != nil { + return fmt.Errorf("remove %s failed: %w", dir, err) + } + } + } + + if !this.options.IsTesting { + matches, _ := filepath.Glob(this.options.SrcDir + "/*.zip") + for _, filePath := range matches { + err := os.Remove(filePath) + if err != nil { + return fmt.Errorf("remove %s failed: %w", filePath, err) + } + } + } + + return nil +} + +func (this *Instance) Startup() error { + if this.options.Verbose { + log.Println("startup ...") + } + + type NodeInfo struct { + Role string + Ports []int + } + + for _, node := range []NodeInfo{ + {"api", []int{this.options.APINode.HTTPPort, this.options.APINode.RestHTTPPort}}, + {"admin", []int{this.options.AdminNode.Port}}, + {"node", []int{this.options.Node.HTTPPort}}, + {"user", []int{this.options.UserNode.HTTPPort}}, + } { + var exe = this.targetPrefix() + "/edge-" + node.Role + "/bin/edge-" + node.Role + _, err := os.Stat(exe) + if err != nil { + continue + } + + var cmd = executils.NewTimeoutCmd(30*time.Second, exe, "start") + err = cmd.Run() + if err != nil { + return fmt.Errorf("start '"+node.Role+"' failed: %w", err) + } + + for _, port := range node.Ports { + var isOk bool + for i := 0; i < 30; /** seconds **/ i++ { + conn, connErr := net.DialTimeout("tcp", ":"+types.String(port), 5*time.Second) + if connErr != nil { + time.Sleep(1 * time.Second) + continue + } + _ = conn.Close() + isOk = true + } + if !isOk { + return fmt.Errorf("waiting '%s' port '%d' timeout", node.Role, port) + } + } + } + + return nil +} + +func (this *Instance) dbInstance(enableDAO bool) (*dbs.DB, error) { + Tea.Env = "prod" + db, err := dbs.NewInstanceFromConfig(&dbs.DBConfig{ + Driver: "mysql", + Dsn: this.options.DB.Username + ":" + this.options.DB.Password + "@tcp(" + this.options.DB.Host + ":" + types.String(this.options.DB.Port) + ")/" + this.options.DB.Name + "?charset=utf8mb4&timeout=30s", + Prefix: "edge", + }) + if err != nil { + return nil, fmt.Errorf("create database instance failed: %w", err) + } + + if enableDAO { + this.setupDAO(db) + } + + return db, nil +} + +func (this *Instance) dbInstanceWithoutDBName() (*dbs.DB, error) { + Tea.Env = "prod" + db, err := dbs.NewInstanceFromConfig(&dbs.DBConfig{ + Driver: "mysql", + Dsn: this.options.DB.Username + ":" + this.options.DB.Password + "@tcp(" + this.options.DB.Host + ":" + types.String(this.options.DB.Port) + ")/?charset=utf8mb4&timeout=30s", + Prefix: "edge", + }) + if err != nil { + return nil, fmt.Errorf("create database instance failed: %w", err) + } + + return db, nil +} + +func (this *Instance) setupDAO(db *dbs.DB) { + dbConfig, err := db.Config() + if err == nil { + dbs.GlobalConfig().DBs = map[string]*dbs.DBConfig{ + "dev": dbConfig, + "prod": dbConfig, + } + } + + { + models.SharedNodeClusterDAO = models.NewNodeClusterDAO() + models.SharedNodeClusterDAO.Instance = db + } + + { + models.SharedNodeDAO = models.NewNodeDAO() + models.SharedNodeDAO.Instance = db + } + + { + models.SharedNodeTaskDAO = models.NewNodeTaskDAO() + models.SharedNodeTaskDAO.Instance = db + } + + { + models.SharedSysLockerDAO = models.NewSysLockerDAO() + models.SharedSysLockerDAO.Instance = db + } + + { + models.SharedAdminDAO = models.NewAdminDAO() + models.SharedAdminDAO.Instance = db + } + + { + models.SharedAPINodeDAO = models.NewAPINodeDAO() + models.SharedAPINodeDAO.Instance = db + } + + { + models.SharedApiTokenDAO = models.NewApiTokenDAO() + models.SharedApiTokenDAO.Instance = db + } + + { + models.SharedUserDAO = models.NewUserDAO() + models.SharedUserDAO.Instance = db + } + + { + models.SharedUserNodeDAO = models.NewUserNodeDAO() + models.SharedUserNodeDAO.Instance = db + } + + { + models.SharedServerDAO = models.NewServerDAO() + models.SharedServerDAO.Instance = db + } + + { + models.SharedHTTPWebDAO = models.NewHTTPWebDAO() + models.SharedHTTPWebDAO.Instance = db + } + + { + models.SharedHTTPWebsocketDAO = models.NewHTTPWebsocketDAO() + models.SharedHTTPWebsocketDAO.Instance = db + } + + { + models.SharedHTTPLocationDAO = models.NewHTTPLocationDAO() + models.SharedHTTPLocationDAO.Instance = db + } + + { + models.SharedServerGroupDAO = models.NewServerGroupDAO() + models.SharedServerGroupDAO.Instance = db + } + + { + models.SharedReverseProxyDAO = models.NewReverseProxyDAO() + models.SharedReverseProxyDAO.Instance = db + } +} + +func (this *Instance) unzip(role string, version string) error { + if !regexp.MustCompile(`^\w+$`).MatchString(role) { + return errors.New("invalid role '" + role + "'") + } + + var arch = runtime.GOARCH + if runtime.GOOS != "linux" { + arch = "amd64" + } + var plusTag string + if this.isPlus() && lists.ContainsString([]string{nodeconfigs.NodeRoleAdmin, nodeconfigs.NodeRoleAPI, nodeconfigs.NodeRoleNode}, role) { + plusTag = "-plus" + } + + var zipFile = this.options.SrcDir + "/edge-" + role + "-linux-" + arch + plusTag + "-v" + version + ".zip" + { + stat, err := os.Stat(zipFile) + if err != nil { + return fmt.Errorf("stat '"+zipFile+"' failed: %w", err) + } + if stat.IsDir() { + return errors.New("'" + zipFile + "' should be a file instead of directory") + } + } + + var targetPrefix = this.targetPrefix() + + { + stat, err := os.Stat(targetPrefix) + if err != nil || !stat.IsDir() { + err = os.MkdirAll(targetPrefix, 0777) + if err != nil { + return fmt.Errorf("create directory '"+targetPrefix+"' failed: %w", err) + } + } + } + + if this.options.Cacheable { + var targetDir = targetPrefix + "/edge-" + role + _, err := os.Stat(targetDir) + if err == nil { + return nil + } + } + + var unzip = helpers.NewUnzip(zipFile, targetPrefix) + return unzip.Run() +} + +func (this *Instance) targetPrefix() string { + return this.options.WorkDir + "/usr/local/goedge" +} + +func (this *Instance) isPlus() bool { + return teaconst.Tag == "plus" +} diff --git a/internal/instances/instance_test.go b/internal/instances/instance_test.go new file mode 100644 index 00000000..ed853a43 --- /dev/null +++ b/internal/instances/instance_test.go @@ -0,0 +1,98 @@ +// Copyright 2024 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package instances_test + +import ( + "github.com/TeaOSLab/EdgeAPI/internal/instances" + "github.com/iwind/TeaGo/Tea" + _ "github.com/iwind/TeaGo/bootstrap" + "testing" +) + +var instance = instances.NewInstance(instances.Options{ + Cacheable: true, + WorkDir: Tea.Root + "/standalone-instance", + SrcDir: Tea.Root + "/standalone-instance/src", + DB: struct { + Host string + Port int + Username string + Password string + Name string + }{ + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Password: "123456", + Name: "edges2", + }, + AdminNode: struct { + Port int + }{ + Port: 7788, + }, + APINode: struct { + HTTPPort int + RestHTTPPort int + }{ + HTTPPort: 8001, + RestHTTPPort: 8002, + }, + Node: struct{ HTTPPort int }{ + HTTPPort: 8080, + }, + UserNode: struct { + HTTPPort int + }{ + HTTPPort: 7799, + }, +}) + +func TestInstanceSetupAll(t *testing.T) { + err := instance.SetupAll() + if err != nil { + t.Fatal(err) + } +} + +func TestInstance_SetupDB(t *testing.T) { + err := instance.SetupDB() + if err != nil { + t.Fatal(err) + } +} + +func TestInstance_SetupAdminNode(t *testing.T) { + err := instance.SetupAdminNode() + if err != nil { + t.Fatal(err) + } +} + +func TestInstance_SetupAPINode(t *testing.T) { + err := instance.SetupAPINode() + if err != nil { + t.Fatal(err) + } +} + +func TestInstance_SetupUserNode(t *testing.T) { + err := instance.SetupUserNode() + if err != nil { + t.Fatal(err) + } +} + +func TestInstance_SetupNode(t *testing.T) { + err := instance.SetupNode() + if err != nil { + t.Fatal(err) + } +} + +func TestInstance_Clean(t *testing.T) { + err := instance.Clean() + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/instances/options.go b/internal/instances/options.go new file mode 100644 index 00000000..f8938993 --- /dev/null +++ b/internal/instances/options.go @@ -0,0 +1,32 @@ +// Copyright 2024 GoEdge CDN goedge.cdn@gmail.com. All rights reserved. Official site: https://goedge.cn . + +package instances + +type Options struct { + IsTesting bool + Verbose bool + Cacheable bool + + WorkDir string + SrcDir string + DB struct { + Host string + Port int + Username string + Password string + Name string + } + AdminNode struct { + Port int + } + APINode struct { + HTTPPort int + RestHTTPPort int + } + Node struct { + HTTPPort int + } + UserNode struct { + HTTPPort int + } +}