mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 05:31:44 +08:00
546 lines
16 KiB
Go
546 lines
16 KiB
Go
package data
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/coreos/go-systemd/v22/unit"
|
|
"github.com/leonelquinteros/gotext"
|
|
"github.com/samber/lo"
|
|
"github.com/spf13/cast"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/acepanel/panel/internal/biz"
|
|
"github.com/acepanel/panel/internal/http/request"
|
|
"github.com/acepanel/panel/pkg/systemctl"
|
|
"github.com/acepanel/panel/pkg/types"
|
|
)
|
|
|
|
type projectRepo struct {
|
|
t *gotext.Locale
|
|
db *gorm.DB
|
|
log *slog.Logger
|
|
}
|
|
|
|
func NewProjectRepo(t *gotext.Locale, db *gorm.DB, log *slog.Logger) biz.ProjectRepo {
|
|
return &projectRepo{
|
|
t: t,
|
|
db: db,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
func (r *projectRepo) Count() (int64, error) {
|
|
var count int64
|
|
if err := r.db.Model(&biz.Project{}).Count(&count).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (r *projectRepo) List(typ types.ProjectType, page, limit uint) ([]*types.ProjectDetail, int64, error) {
|
|
var projects []*biz.Project
|
|
var total int64
|
|
|
|
query := r.db.Model(&biz.Project{})
|
|
if typ != "" && typ != "all" {
|
|
query = query.Where("type = ?", typ)
|
|
}
|
|
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err := query.Offset(int((page - 1) * limit)).Limit(int(limit)).Order("id desc").Find(&projects).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
details := make([]*types.ProjectDetail, 0, len(projects))
|
|
for _, p := range projects {
|
|
detail, err := r.parseProjectDetail(p)
|
|
if err != nil {
|
|
// 如果解析失败,返回基本信息
|
|
detail = &types.ProjectDetail{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
Type: p.Type,
|
|
}
|
|
}
|
|
details = append(details, detail)
|
|
}
|
|
|
|
return details, total, nil
|
|
}
|
|
|
|
func (r *projectRepo) Get(id uint) (*types.ProjectDetail, error) {
|
|
project := new(biz.Project)
|
|
if err := r.db.First(project, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return r.parseProjectDetail(project)
|
|
}
|
|
|
|
func (r *projectRepo) Create(ctx context.Context, req *request.ProjectCreate) (*types.ProjectDetail, error) {
|
|
// 检查项目名是否已存在
|
|
var count int64
|
|
if err := r.db.Model(&biz.Project{}).Where("name = ?", req.Name).Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if count > 0 {
|
|
return nil, errors.New(r.t.Get("project name already exists"))
|
|
}
|
|
|
|
project := &biz.Project{
|
|
Name: req.Name,
|
|
Type: req.Type,
|
|
Path: req.RootDir,
|
|
}
|
|
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
// 创建数据库记录
|
|
if err := tx.Create(project).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// 生成 systemd unit 文件
|
|
if err := r.generateUnitFile(req); err != nil {
|
|
return fmt.Errorf("%s: %w", r.t.Get("failed to generate systemd config"), err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 记录日志
|
|
r.log.Info("project created", slog.String("type", biz.OperationTypeProject), slog.Uint64("operator_id", getOperatorID(ctx)), slog.String("name", req.Name), slog.String("project_type", string(req.Type)))
|
|
|
|
return r.parseProjectDetail(project)
|
|
}
|
|
|
|
func (r *projectRepo) Update(ctx context.Context, req *request.ProjectUpdate) error {
|
|
project := new(biz.Project)
|
|
if err := r.db.First(project, req.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// 如果名称变更,需要重命名 unit 文件
|
|
if req.Name != project.Name {
|
|
oldPath := r.unitFilePath(project.Name)
|
|
newPath := r.unitFilePath(req.Name)
|
|
if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("%s: %w", r.t.Get("failed to rename systemd config"), err)
|
|
}
|
|
project.Name = req.Name
|
|
}
|
|
|
|
project.Path = req.RootDir
|
|
if err := r.db.Save(project).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// 记录日志
|
|
r.log.Info("project updated", slog.String("type", biz.OperationTypeProject), slog.Uint64("operator_id", getOperatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("name", project.Name))
|
|
|
|
// 更新 systemd unit 文件
|
|
return r.updateUnitFile(project.Name, req)
|
|
}
|
|
|
|
func (r *projectRepo) Delete(ctx context.Context, id uint) error {
|
|
project := new(biz.Project)
|
|
if err := r.db.First(project, id).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// 删除 systemd unit 文件
|
|
unitPath := r.unitFilePath(project.Name)
|
|
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("%s: %w", r.t.Get("failed to delete systemd config"), err)
|
|
}
|
|
|
|
if err := r.db.Delete(project).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// 记录日志
|
|
r.log.Info("project deleted", slog.String("type", biz.OperationTypeProject), slog.Uint64("operator_id", getOperatorID(ctx)), slog.Uint64("id", uint64(id)), slog.String("name", project.Name))
|
|
|
|
return nil
|
|
}
|
|
|
|
// unitFilePath 返回 systemd unit 文件路径
|
|
func (r *projectRepo) unitFilePath(name string) string {
|
|
return filepath.Join("/etc/systemd/system", fmt.Sprintf("%s.service", name))
|
|
}
|
|
|
|
// parseProjectDetail 从数据库记录和 systemd unit 文件解析项目详情
|
|
func (r *projectRepo) parseProjectDetail(project *biz.Project) (*types.ProjectDetail, error) {
|
|
detail := &types.ProjectDetail{
|
|
ID: project.ID,
|
|
Name: project.Name,
|
|
Type: project.Type,
|
|
RootDir: project.Path,
|
|
}
|
|
|
|
// 读取并解析 systemd unit 文件
|
|
unitPath := r.unitFilePath(project.Name)
|
|
file, err := os.Open(unitPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return detail, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer func(file *os.File) { _ = file.Close() }(file)
|
|
|
|
options, err := unit.DeserializeOptions(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", r.t.Get("failed to parse systemd config"), err)
|
|
}
|
|
|
|
// 解析各个字段
|
|
for _, opt := range options {
|
|
switch opt.Section {
|
|
case "Unit":
|
|
r.parseUnitSection(detail, opt)
|
|
case "Service":
|
|
r.parseServiceSection(detail, opt)
|
|
}
|
|
}
|
|
|
|
// 获取运行状态
|
|
if info, err := systemctl.GetServiceInfo(project.Name); err == nil {
|
|
detail.Status = info.Status
|
|
detail.PID = info.PID
|
|
detail.Memory = info.Memory
|
|
detail.CPU = info.CPU
|
|
detail.Uptime = info.Uptime
|
|
}
|
|
|
|
// 获取是否自启动
|
|
if enabled, err := systemctl.IsEnabled(project.Name); err == nil {
|
|
detail.Enabled = enabled
|
|
}
|
|
|
|
return detail, nil
|
|
}
|
|
|
|
// parseUnitSection 解析 [Unit] 部分
|
|
func (r *projectRepo) parseUnitSection(detail *types.ProjectDetail, opt *unit.UnitOption) {
|
|
switch opt.Name {
|
|
case "Description":
|
|
detail.Description = opt.Value
|
|
case "Requires":
|
|
detail.Requires = append(detail.Requires, opt.Value)
|
|
case "Wants":
|
|
detail.Wants = append(detail.Wants, opt.Value)
|
|
case "After":
|
|
detail.After = append(detail.After, opt.Value)
|
|
case "Before":
|
|
detail.Before = append(detail.Before, opt.Value)
|
|
}
|
|
}
|
|
|
|
// parseServiceSection 解析 [Service] 部分
|
|
func (r *projectRepo) parseServiceSection(detail *types.ProjectDetail, opt *unit.UnitOption) {
|
|
switch opt.Name {
|
|
case "WorkingDirectory":
|
|
detail.WorkingDir = opt.Value
|
|
case "ExecStartPre":
|
|
detail.ExecStartPre = opt.Value
|
|
case "ExecStartPost":
|
|
detail.ExecStartPost = opt.Value
|
|
case "ExecStart":
|
|
detail.ExecStart = opt.Value
|
|
case "ExecStop":
|
|
detail.ExecStop = opt.Value
|
|
case "ExecReload":
|
|
detail.ExecReload = opt.Value
|
|
case "User":
|
|
detail.User = opt.Value
|
|
case "Restart":
|
|
detail.Restart = opt.Value
|
|
case "RestartSec":
|
|
detail.RestartSec = opt.Value
|
|
case "StartLimitBurst":
|
|
if v, err := strconv.Atoi(opt.Value); err == nil {
|
|
detail.RestartMax = v
|
|
}
|
|
case "TimeoutStartSec":
|
|
if v, err := strconv.Atoi(opt.Value); err == nil {
|
|
detail.TimeoutStartSec = v
|
|
}
|
|
case "TimeoutStopSec":
|
|
if v, err := strconv.Atoi(opt.Value); err == nil {
|
|
detail.TimeoutStopSec = v
|
|
}
|
|
case "Environment":
|
|
// 格式: KEY=VALUE
|
|
if kv := r.parseEnvironment(opt.Value); kv != nil {
|
|
detail.Environments = append(detail.Environments, *kv)
|
|
}
|
|
case "StandardOutput":
|
|
detail.StandardOutput = opt.Value
|
|
case "StandardError":
|
|
detail.StandardError = opt.Value
|
|
case "MemoryLimit":
|
|
if v, err := r.parseBytes(opt.Value); err == nil {
|
|
detail.MemoryLimit = v
|
|
}
|
|
case "CPUQuota":
|
|
if v, err := r.parsePercent(opt.Value); err == nil {
|
|
detail.CPUQuota = v
|
|
}
|
|
case "NoNewPrivileges":
|
|
detail.NoNewPrivileges = opt.Value == "true" || opt.Value == "yes"
|
|
case "ProtectTmp":
|
|
detail.ProtectTmp = opt.Value == "true" || opt.Value == "yes"
|
|
case "ProtectHome":
|
|
detail.ProtectHome = opt.Value == "true" || opt.Value == "yes"
|
|
case "ProtectSystem":
|
|
detail.ProtectSystem = opt.Value
|
|
case "ReadWritePaths":
|
|
detail.ReadWritePaths = append(detail.ReadWritePaths, opt.Value)
|
|
case "ReadOnlyPaths":
|
|
detail.ReadOnlyPaths = append(detail.ReadOnlyPaths, opt.Value)
|
|
}
|
|
}
|
|
|
|
// parseEnvironment 解析环境变量
|
|
func (r *projectRepo) parseEnvironment(value string) *types.KV {
|
|
parts := strings.SplitN(value, "=", 2)
|
|
if len(parts) != 2 {
|
|
return nil
|
|
}
|
|
return &types.KV{Key: parts[0], Value: parts[1]}
|
|
}
|
|
|
|
// parseBytes 解析字节大小 (如 512M, 1G)
|
|
func (r *projectRepo) parseBytes(value string) (float64, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return 0, errors.New("empty value")
|
|
}
|
|
|
|
multiplier := float64(1)
|
|
suffix := value[len(value)-1]
|
|
switch suffix {
|
|
case 'K', 'k':
|
|
multiplier = 1024
|
|
value = value[:len(value)-1]
|
|
case 'M', 'm':
|
|
multiplier = 1024 * 1024
|
|
value = value[:len(value)-1]
|
|
case 'G', 'g':
|
|
multiplier = 1024 * 1024 * 1024
|
|
value = value[:len(value)-1]
|
|
}
|
|
|
|
return cast.ToFloat64(value) * multiplier, nil
|
|
}
|
|
|
|
// formatBytes 格式化字节大小
|
|
func (r *projectRepo) formatBytes(bytes float64) string {
|
|
b := int64(bytes)
|
|
if b >= 1024*1024*1024 && b%(1024*1024*1024) == 0 {
|
|
return fmt.Sprintf("%dG", b/(1024*1024*1024))
|
|
}
|
|
if b >= 1024*1024 && b%(1024*1024) == 0 {
|
|
return fmt.Sprintf("%dM", b/(1024*1024))
|
|
}
|
|
if b >= 1024 && b%1024 == 0 {
|
|
return fmt.Sprintf("%dK", b/1024)
|
|
}
|
|
return strconv.FormatInt(b, 10)
|
|
}
|
|
|
|
// parsePercent 解析百分比 (如 50%)
|
|
func (r *projectRepo) parsePercent(value string) (float64, error) {
|
|
value = strings.TrimSuffix(value, "%")
|
|
return strconv.ParseFloat(value, 64)
|
|
}
|
|
|
|
// generateUnitFile 生成 systemd unit 文件
|
|
func (r *projectRepo) generateUnitFile(req *request.ProjectCreate) error {
|
|
req.RootDir = lo.If(!strings.HasPrefix(req.RootDir, "/"), filepath.Join("/", req.RootDir)).Else(req.RootDir)
|
|
req.WorkingDir = lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)
|
|
req.WorkingDir = lo.If(!strings.HasPrefix(req.WorkingDir, "/"), filepath.Join("/", req.WorkingDir)).Else(req.WorkingDir)
|
|
options := []*unit.UnitOption{
|
|
// [Unit] section
|
|
unit.NewUnitOption("Unit", "Description", req.Description),
|
|
unit.NewUnitOption("Unit", "After", "network.target"),
|
|
|
|
// [Service] section
|
|
unit.NewUnitOption("Service", "Type", "simple"),
|
|
unit.NewUnitOption("Service", "WorkingDirectory", req.WorkingDir),
|
|
}
|
|
|
|
if req.ExecStart != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "ExecStart", req.ExecStart))
|
|
}
|
|
if req.User != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "User", req.User))
|
|
}
|
|
if req.Restart != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "Restart", req.Restart))
|
|
} else {
|
|
options = append(options, unit.NewUnitOption("Service", "Restart", "on-failure"))
|
|
}
|
|
|
|
// 环境变量
|
|
for _, env := range req.Environments {
|
|
options = append(options, unit.NewUnitOption("Service", "Environment", fmt.Sprintf("%s=%s", env.Key, env.Value)))
|
|
}
|
|
|
|
// [Install] section
|
|
options = append(options, unit.NewUnitOption("Install", "WantedBy", "multi-user.target"))
|
|
|
|
// 写入文件
|
|
unitPath := r.unitFilePath(req.Name)
|
|
reader := unit.Serialize(options)
|
|
content, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = os.WriteFile(unitPath, content, 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
return systemctl.DaemonReload()
|
|
}
|
|
|
|
// updateUnitFile 更新 systemd unit 文件
|
|
func (r *projectRepo) updateUnitFile(name string, req *request.ProjectUpdate) error {
|
|
req.RootDir = lo.If(!strings.HasPrefix(req.RootDir, "/"), filepath.Join("/", req.RootDir)).Else(req.RootDir)
|
|
req.WorkingDir = lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)
|
|
req.WorkingDir = lo.If(!strings.HasPrefix(req.WorkingDir, "/"), filepath.Join("/", req.WorkingDir)).Else(req.WorkingDir)
|
|
|
|
options := []*unit.UnitOption{
|
|
// [Unit] section
|
|
unit.NewUnitOption("Unit", "Description", req.Description),
|
|
}
|
|
|
|
// Unit 依赖
|
|
for _, v := range req.Requires {
|
|
options = append(options, unit.NewUnitOption("Unit", "Requires", v))
|
|
}
|
|
for _, v := range req.Wants {
|
|
options = append(options, unit.NewUnitOption("Unit", "Wants", v))
|
|
}
|
|
for _, v := range req.After {
|
|
options = append(options, unit.NewUnitOption("Unit", "After", v))
|
|
}
|
|
if len(req.After) == 0 {
|
|
options = append(options, unit.NewUnitOption("Unit", "After", "network.target"))
|
|
}
|
|
for _, v := range req.Before {
|
|
options = append(options, unit.NewUnitOption("Unit", "Before", v))
|
|
}
|
|
|
|
// [Service] section
|
|
options = append(options, unit.NewUnitOption("Service", "Type", "simple"))
|
|
options = append(options, unit.NewUnitOption("Service", "WorkingDirectory", req.WorkingDir))
|
|
|
|
if req.ExecStartPre != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "ExecStartPre", req.ExecStartPre))
|
|
}
|
|
if req.ExecStart != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "ExecStart", req.ExecStart))
|
|
}
|
|
if req.ExecStartPost != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "ExecStartPost", req.ExecStartPost))
|
|
}
|
|
if req.ExecStop != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "ExecStop", req.ExecStop))
|
|
}
|
|
if req.ExecReload != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "ExecReload", req.ExecReload))
|
|
}
|
|
if req.User != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "User", req.User))
|
|
}
|
|
if req.Restart != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "Restart", req.Restart))
|
|
} else {
|
|
options = append(options, unit.NewUnitOption("Service", "Restart", "on-failure"))
|
|
}
|
|
if req.RestartSec != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "RestartSec", req.RestartSec))
|
|
}
|
|
if req.RestartMax > 0 {
|
|
options = append(options, unit.NewUnitOption("Service", "StartLimitBurst", strconv.Itoa(req.RestartMax)))
|
|
}
|
|
if req.TimeoutStartSec > 0 {
|
|
options = append(options, unit.NewUnitOption("Service", "TimeoutStartSec", strconv.Itoa(req.TimeoutStartSec)))
|
|
}
|
|
if req.TimeoutStopSec > 0 {
|
|
options = append(options, unit.NewUnitOption("Service", "TimeoutStopSec", strconv.Itoa(req.TimeoutStopSec)))
|
|
}
|
|
|
|
// 环境变量
|
|
for _, env := range req.Environments {
|
|
options = append(options, unit.NewUnitOption("Service", "Environment", fmt.Sprintf("%s=%s", env.Key, env.Value)))
|
|
}
|
|
|
|
// 输出
|
|
if req.StandardOutput != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "StandardOutput", req.StandardOutput))
|
|
}
|
|
if req.StandardError != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "StandardError", req.StandardError))
|
|
}
|
|
|
|
// 资源限制
|
|
if req.MemoryLimit > 0 {
|
|
options = append(options, unit.NewUnitOption("Service", "MemoryLimit", r.formatBytes(req.MemoryLimit)))
|
|
}
|
|
if req.CPUQuota != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "CPUQuota", req.CPUQuota))
|
|
}
|
|
|
|
// 安全选项
|
|
if req.NoNewPrivileges {
|
|
options = append(options, unit.NewUnitOption("Service", "NoNewPrivileges", "true"))
|
|
}
|
|
if req.ProtectTmp {
|
|
options = append(options, unit.NewUnitOption("Service", "ProtectTmp", "true"))
|
|
}
|
|
if req.ProtectHome {
|
|
options = append(options, unit.NewUnitOption("Service", "ProtectHome", "true"))
|
|
}
|
|
if req.ProtectSystem != "" {
|
|
options = append(options, unit.NewUnitOption("Service", "ProtectSystem", req.ProtectSystem))
|
|
}
|
|
for _, v := range req.ReadWritePaths {
|
|
options = append(options, unit.NewUnitOption("Service", "ReadWritePaths", v))
|
|
}
|
|
for _, v := range req.ReadOnlyPaths {
|
|
options = append(options, unit.NewUnitOption("Service", "ReadOnlyPaths", v))
|
|
}
|
|
|
|
// [Install] section
|
|
options = append(options, unit.NewUnitOption("Install", "WantedBy", "multi-user.target"))
|
|
|
|
// 写入文件
|
|
unitPath := r.unitFilePath(name)
|
|
reader := unit.Serialize(options)
|
|
content, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = os.WriteFile(unitPath, content, 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
return systemctl.DaemonReload()
|
|
}
|