feat: LokiDeploy 初始提交 - 轻量级 K8s Loki 部署平台

Go(Gin) + React(Vite + shadcn/ui + Tailwind) 前后端分离,全中文 UI

- 集群管理:Kubeconfig / Token 双接入,kubectl + helm 部署
- 主机管理:SSH 密钥 / 密码双认证,CPU/内存/磁盘使用率监控,SSH 错误中文友好提示
- 存储配置、部署管理、系统设置
- Docker 镜像内置 kubectl + helm,开箱即用
- run.sh 一键启停(start/stop/restart/status)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Logan.LiandClaude committed 2026-06-29 17:03:57 +08:00
commit 2a93de600d
134 files changed
+19595

No files matched your search

+1
View File
@@ -0,0 +1 @@
46832
+42
View File
@@ -0,0 +1,42 @@
package config
import (
"os"
"path/filepath"
"sync"
)
var (
instance *Config
once sync.Once
)
type Config struct {
ServerPort string
DatabasePath string
JWTSecret string
DataDir string
KubeDir string
}
func GetConfig() *Config {
once.Do(func() {
dataDir := getEnv("DATA_DIR", "./data")
instance = &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
DatabasePath: filepath.Join(dataDir, "lokideploy.json"),
JWTSecret: getEnv("JWT_SECRET", "lokideploy-secret-key-change-in-production"),
DataDir: dataDir,
KubeDir: filepath.Join(dataDir, "kubeconfigs"),
}
_ = os.MkdirAll(instance.KubeDir, 0755)
})
return instance
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
+94
View File
@@ -0,0 +1,94 @@
package controllers
import (
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
"github.com/gin-gonic/gin"
)
type AuthController struct {
service *services.AuthService
}
func NewAuthController() *AuthController {
return &AuthController{service: services.NewAuthService()}
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type RegisterRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
Role string `json:"role"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"oldPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=6"`
}
func (ctrl *AuthController) Login(c *gin.Context) {
var req LoginRequest
if !utils.BindAndValidate(c, &req) {
return
}
user, token, err := ctrl.service.Login(req.Username, req.Password)
if err != nil {
utils.Unauthorized(c, "用户名或密码错误")
return
}
utils.Success(c, gin.H{"token": token, "user": models.SanitizedJSON(user)})
}
func (ctrl *AuthController) Register(c *gin.Context) {
var req RegisterRequest
if !utils.BindAndValidate(c, &req) {
return
}
if req.Role == "" {
req.Role = "user"
}
user, err := ctrl.service.Register(req.Username, req.Password, req.Nickname, req.Role)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(user))
}
func (ctrl *AuthController) GetUser(c *gin.Context) {
claims, ok := utils.GetUserFromContext(c)
if !ok {
utils.Unauthorized(c, "")
return
}
user, err := ctrl.service.GetUserByID(claims.UserID)
if err != nil {
utils.Error(c, 404, "用户不存在")
return
}
utils.Success(c, models.SanitizedJSON(user))
}
func (ctrl *AuthController) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if !utils.BindAndValidate(c, &req) {
return
}
claims, ok := utils.GetUserFromContext(c)
if !ok {
utils.Unauthorized(c, "")
return
}
if err := ctrl.service.ChangePassword(claims.UserID, req.OldPassword, req.NewPassword); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "password changed"})
}
+137
View File
@@ -0,0 +1,137 @@
package controllers
import (
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
"github.com/gin-gonic/gin"
)
type ClusterController struct {
service *services.ClusterService
}
func NewClusterController() *ClusterController {
return &ClusterController{service: services.NewClusterService()}
}
type CreateClusterRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
ConfigType string `json:"configType"` // kubeconfig | token
APIServer string `json:"apiServer"` // token 模式必填
KubeConfig string `json:"kubeconfig"` // kubeconfig 模式必填
Token string `json:"token"` // token 模式必填
Context string `json:"context"`
}
func (ctrl *ClusterController) Create(c *gin.Context) {
var req CreateClusterRequest
if !utils.BindAndValidate(c, &req) {
return
}
if req.ConfigType == "" {
req.ConfigType = "kubeconfig"
}
if req.ConfigType == "kubeconfig" && req.KubeConfig == "" {
utils.BadRequest(c, "请填写 kubeconfig 内容")
return
}
if req.ConfigType == "token" && (req.APIServer == "" || req.Token == "") {
utils.BadRequest(c, "Token 接入方式需要填写 API Server 和 Token")
return
}
cluster, err := ctrl.service.Create(req.Name, req.Description, req.ConfigType, req.APIServer, req.KubeConfig, req.Token, req.Context)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(cluster))
}
func (ctrl *ClusterController) Get(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
cluster, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "集群不存在")
return
}
utils.Success(c, models.SanitizedJSON(cluster))
}
func (ctrl *ClusterController) List(c *gin.Context) {
page, pageSize := utils.Pagination(c)
clusters, total, err := ctrl.service.List(page, pageSize)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, utils.PageData{
List: models.SanitizedJSON(clusters), Total: total, Page: page, PageSize: pageSize,
})
}
func (ctrl *ClusterController) Update(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var updates map[string]interface{}
if !utils.BindAndValidate(c, &updates) {
return
}
if err := ctrl.service.Update(uint(id), updates); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "更新成功"})
}
func (ctrl *ClusterController) Delete(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := ctrl.service.Delete(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "删除成功"})
}
func (ctrl *ClusterController) Validate(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
go ctrl.service.Validate(uint(id))
utils.Success(c, gin.H{"message": "已开始验证连接"})
}
func (ctrl *ClusterController) GetInfo(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
cluster, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "集群不存在")
return
}
info, err := services.GetClusterInfo(c.Request.Context(), cluster)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, info)
}
+182
View File
@@ -0,0 +1,182 @@
package controllers
import (
"lokideploy/services"
"lokideploy/utils"
"github.com/gin-gonic/gin"
)
type DeploymentController struct {
service *services.DeploymentService
}
func NewDeploymentController() *DeploymentController {
return &DeploymentController{service: services.NewDeploymentService()}
}
type CreateDeploymentRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Version string `json:"version"`
Mode string `json:"mode" binding:"required,oneof=k8s-helm k8s-yaml host-binary"`
ClusterID *uint `json:"clusterID"`
HostID *uint `json:"hostID"`
Namespace string `json:"namespace"`
StorageConfigID uint `json:"storageConfigID"`
AuthEnabled bool `json:"authEnabled"`
RetentionDays int `json:"retentionDays"`
Replicas int `json:"replicas"`
ResourcesCPU string `json:"resourcesCPU"`
ResourcesMem string `json:"resourcesMem"`
StorageSize string `json:"storageSize"`
}
func (ctrl *DeploymentController) Create(c *gin.Context) {
var req CreateDeploymentRequest
if !utils.BindAndValidate(c, &req) {
return
}
deployment, err := ctrl.service.Create(&services.CreateDeploymentRequest{
Name: req.Name,
Description: req.Description,
Version: req.Version,
Mode: req.Mode,
ClusterID: req.ClusterID,
HostID: req.HostID,
Namespace: req.Namespace,
StorageConfigID: req.StorageConfigID,
AuthEnabled: req.AuthEnabled,
RetentionDays: req.RetentionDays,
Replicas: req.Replicas,
ResourcesCPU: req.ResourcesCPU,
ResourcesMem: req.ResourcesMem,
StorageSize: req.StorageSize,
})
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, deployment)
}
func (ctrl *DeploymentController) Get(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
deployment, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "部署不存在")
return
}
utils.Success(c, deployment)
}
func (ctrl *DeploymentController) List(c *gin.Context) {
page, pageSize := utils.Pagination(c)
deployments, total, err := ctrl.service.List(page, pageSize)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, utils.PageData{
List: deployments, Total: total, Page: page, PageSize: pageSize,
})
}
func (ctrl *DeploymentController) Update(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var updates map[string]interface{}
if !utils.BindAndValidate(c, &updates) {
return
}
if err := ctrl.service.Update(uint(id), updates); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "updated"})
}
func (ctrl *DeploymentController) Delete(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := ctrl.service.Delete(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deleted"})
}
func (ctrl *DeploymentController) Deploy(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := ctrl.service.StartDeploy(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deployment started"})
}
func (ctrl *DeploymentController) Stop(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := ctrl.service.StopDeployment(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deployment stopped"})
}
func (ctrl *DeploymentController) Restart(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := ctrl.service.RestartDeployment(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deployment restarted"})
}
func (ctrl *DeploymentController) GetLogs(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
page, pageSize := utils.Pagination(c)
logs, total, err := ctrl.service.GetLogs(uint(id), page, pageSize)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, utils.PageData{
List: logs, Total: total, Page: page, PageSize: pageSize,
})
}
func (ctrl *DeploymentController) GetStats(c *gin.Context) {
stats, err := ctrl.service.GetStats()
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, stats)
}
+164
View File
@@ -0,0 +1,164 @@
package controllers
import (
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
"github.com/gin-gonic/gin"
)
type HostController struct {
service *services.HostService
}
func NewHostController() *HostController {
return &HostController{service: services.NewHostService()}
}
type CreateHostRequest struct {
Name string `json:"name" binding:"required"`
HostIP string `json:"hostIP" binding:"required"`
Port int `json:"port"`
User string `json:"user" binding:"required"`
AuthType string `json:"authType" binding:"required,oneof=password key"`
Password string `json:"password"`
PrivateKey string `json:"privateKey"`
KeyPhrase string `json:"keyPhrase"`
OS string `json:"os"`
Arch string `json:"arch"`
Labels string `json:"labels"`
}
func (ctrl *HostController) Create(c *gin.Context) {
var req CreateHostRequest
if !utils.BindAndValidate(c, &req) {
return
}
host, err := ctrl.service.Create(req.Name, req.HostIP, req.Port, req.User, req.AuthType,
req.Password, req.PrivateKey, req.KeyPhrase, req.OS, req.Arch, req.Labels)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(host))
}
func (ctrl *HostController) Get(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
host, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "主机不存在")
return
}
utils.Success(c, models.SanitizedJSON(host))
}
func (ctrl *HostController) List(c *gin.Context) {
page, pageSize := utils.Pagination(c)
hosts, total, err := ctrl.service.List(page, pageSize)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, utils.PageData{
List: models.SanitizedJSON(hosts),
Total: total, Page: page, PageSize: pageSize,
})
}
func (ctrl *HostController) Update(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var updates map[string]interface{}
if !utils.BindAndValidate(c, &updates) {
return
}
if err := ctrl.service.Update(uint(id), updates); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "updated"})
}
func (ctrl *HostController) Delete(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := ctrl.service.Delete(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deleted"})
}
func (ctrl *HostController) Validate(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
go ctrl.service.Validate(uint(id))
utils.Success(c, gin.H{"message": "validation started"})
}
func (ctrl *HostController) GetInfo(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
host, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "主机不存在")
return
}
password := host.Password
if len(password) > 4 && password[:4] == "ENC:" {
password, _ = utils.Decrypt(password[4:])
}
client, err := services.NewSSHClient(host.HostIP, host.Port, host.User, password)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
defer client.Close()
info, err := client.GetHostInfo(c.Request.Context())
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, info)
}
func (ctrl *HostController) Execute(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var req struct {
Command string `json:"command" binding:"required"`
}
if !utils.BindAndValidate(c, &req) {
return
}
stdout, stderr, err := ctrl.service.ExecuteCommand(uint(id), req.Command)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, gin.H{"stdout": stdout, "stderr": stderr})
}
+110
View File
@@ -0,0 +1,110 @@
package controllers
import (
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
"github.com/gin-gonic/gin"
)
type StorageController struct {
service *services.StorageService
}
func NewStorageController() *StorageController {
return &StorageController{service: services.NewStorageService()}
}
type CreateStorageRequest struct {
Name string `json:"name" binding:"required"`
StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket" binding:"required"`
Region string `json:"region"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
PathPrefix string `json:"pathPrefix"`
Insecure bool `json:"insecure"`
IsDefault bool `json:"isDefault"`
}
func (ctrl *StorageController) Create(c *gin.Context) {
var req CreateStorageRequest
if !utils.BindAndValidate(c, &req) {
return
}
storage, err := ctrl.service.Create(req.Name, req.StorageType, req.Endpoint, req.Bucket,
req.Region, req.AccessKey, req.SecretKey, req.PathPrefix, req.Insecure, req.IsDefault)
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(storage))
}
func (ctrl *StorageController) Get(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
storage, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "存储配置不存在")
return
}
utils.Success(c, models.SanitizedJSON(storage))
}
func (ctrl *StorageController) List(c *gin.Context) {
page, pageSize := utils.Pagination(c)
storages, total, err := ctrl.service.List(page, pageSize)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, utils.PageData{
List: models.SanitizedJSON(storages),
Total: total, Page: page, PageSize: pageSize,
})
}
func (ctrl *StorageController) Update(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var updates map[string]interface{}
if !utils.BindAndValidate(c, &updates) {
return
}
if err := ctrl.service.Update(uint(id), updates); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "updated"})
}
func (ctrl *StorageController) Delete(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := ctrl.service.Delete(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "deleted"})
}
func (ctrl *StorageController) GetDefault(c *gin.Context) {
storage, err := ctrl.service.GetDefault()
if err != nil {
utils.NotFound(c, "未设置默认存储配置")
return
}
utils.Success(c, models.SanitizedJSON(storage))
}
+44
View File
@@ -0,0 +1,44 @@
module lokideploy
go 1.24.0
toolchain go1.24.3
require (
github.com/gin-contrib/cors v1.7.3
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.47.0
)
require (
github.com/bytedance/sonic v1.12.6 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+103
View File
@@ -0,0 +1,103 @@
github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"log"
"lokideploy/config"
"lokideploy/models"
"lokideploy/routers"
"lokideploy/seed"
)
func main() {
cfg := config.GetConfig()
log.Println("Initializing database...")
if err := models.InitDB(); err != nil {
log.Fatalf("Failed to init DB: %v", err)
}
log.Println("Seeding default data...")
seed.Seed()
r := routers.SetupRouter()
log.Printf("LokiDeploy starting on port %s", cfg.ServerPort)
log.Printf("API: http://localhost:%s/api/v1", cfg.ServerPort)
log.Printf("Default login: admin / admin123")
if err := r.Run(":" + cfg.ServerPort); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package middleware
import (
"strings"
"lokideploy/utils"
"github.com/gin-gonic/gin"
)
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
utils.Unauthorized(c, "missing authorization header")
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
utils.Unauthorized(c, "invalid authorization header format")
c.Abort()
return
}
claims, err := utils.ParseToken(parts[1])
if err != nil {
utils.Unauthorized(c, "invalid or expired token")
c.Abort()
return
}
c.Set("user", claims)
c.Set("userId", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Next()
}
}
func AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists || role != "admin" {
utils.Forbidden(c, "admin access required")
c.Abort()
return
}
c.Next()
}
}
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin == "" {
origin = "*"
}
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Authorization, Accept, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
+38
View File
@@ -0,0 +1,38 @@
package models
import "time"
type Cluster struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ConfigType string `json:"configType"` // kubeconfig | token(空值视为 kubeconfig,向后兼容)
APIServer string `json:"apiServer"` // 展示用
KubeConfig string `json:"kubeconfig"` // 统一存储(token 接入时存构造出的等价 kubeconfig)
KubeConfigPath string `json:"kubeconfigPath"`
Context string `json:"context"`
Version string `json:"version"`
NodesCount int `json:"nodesCount"`
Status string `json:"status"`
LastConnected time.Time `json:"lastConnected"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (c *Cluster) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": c.ID,
"name": c.Name,
"description": c.Description,
"configType": c.ConfigType,
"apiServer": c.APIServer,
"context": c.Context,
"version": c.Version,
"nodesCount": c.NodesCount,
"status": c.Status,
"hasKubeconfig": c.KubeConfig != "",
"lastConnected": c.LastConnected,
"createdAt": c.CreatedAt,
"updatedAt": c.UpdatedAt,
}
}
+111
View File
@@ -0,0 +1,111 @@
package models
import (
"encoding/json"
"lokideploy/config"
"os"
"sync"
)
type JsonDB struct {
mu sync.RWMutex
dataFile string
Users []User `json:"users"`
Clusters []Cluster `json:"clusters"`
Hosts []Host `json:"hosts"`
Storages []StorageConfig `json:"storages"`
Deployments []LokiDeployment `json:"deployments"`
Logs []DeploymentLog `json:"logs"`
Settings []SystemSetting `json:"settings"`
sequences map[string]uint
}
var db *JsonDB
func GetDB() *JsonDB {
return db
}
func InitDB() error {
cfg := config.GetConfig()
_ = os.MkdirAll(cfg.DataDir, 0755)
d := &JsonDB{
dataFile: cfg.DatabasePath,
Users: []User{},
Clusters: []Cluster{},
Hosts: []Host{},
Storages: []StorageConfig{},
Deployments: []LokiDeployment{},
Logs: []DeploymentLog{},
Settings: []SystemSetting{},
sequences: map[string]uint{
"users": 0,
"clusters": 0,
"hosts": 0,
"storages": 0,
"deployments": 0,
"logs": 0,
},
}
if data, err := os.ReadFile(d.dataFile); err == nil {
if err := json.Unmarshal(data, d); err == nil {
d.sequences = map[string]uint{}
for _, u := range d.Users { if u.ID > d.sequences["users"] { d.sequences["users"] = u.ID } }
for _, c := range d.Clusters { if c.ID > d.sequences["clusters"] { d.sequences["clusters"] = c.ID } }
for _, h := range d.Hosts { if h.ID > d.sequences["hosts"] { d.sequences["hosts"] = h.ID } }
for _, s := range d.Storages { if s.ID > d.sequences["storages"] { d.sequences["storages"] = s.ID } }
for _, de := range d.Deployments { if de.ID > d.sequences["deployments"] { d.sequences["deployments"] = de.ID } }
for _, l := range d.Logs { if l.ID > d.sequences["logs"] { d.sequences["logs"] = l.ID } }
}
}
db = d
return nil
}
func (d *JsonDB) Save() error {
d.mu.Lock()
defer d.mu.Unlock()
data, err := json.MarshalIndent(d, "", " ")
if err != nil {
return err
}
return os.WriteFile(d.dataFile, data, 0644)
}
func (d *JsonDB) NextID(table string) uint {
d.mu.Lock()
defer d.mu.Unlock()
d.sequences[table]++
return d.sequences[table]
}
func (d *JsonDB) Lock() {
d.mu.Lock()
}
func (d *JsonDB) Unlock() {
d.mu.Unlock()
}
func (d *JsonDB) RLock() {
d.mu.RLock()
}
func (d *JsonDB) RUnlock() {
d.mu.RUnlock()
}
func (d *JsonDB) CreateUser(user *User) error {
d.mu.Lock()
defer d.mu.Unlock()
if user.ID == 0 {
user.ID = d.sequences["users"] + 1
d.sequences["users"] = user.ID
}
d.Users = append(d.Users, *user)
go d.Save()
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package models
import (
"time"
"github.com/google/uuid"
)
type LokiDeployment struct {
ID uint `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Mode string `json:"mode"`
ClusterID *uint `json:"clusterID,omitempty"`
Cluster *Cluster `json:"cluster,omitempty"`
HostID *uint `json:"hostID,omitempty"`
Host *Host `json:"host,omitempty"`
Namespace string `json:"namespace"`
StorageConfigID uint `json:"storageConfigID"`
StorageConfig *StorageConfig `json:"storageConfig,omitempty"`
AuthEnabled bool `json:"authEnabled"`
RetentionDays int `json:"retentionDays"`
Replicas int `json:"replicas"`
ResourcesCPU string `json:"resourcesCPU"`
ResourcesMem string `json:"resourcesMem"`
StorageSize string `json:"storageSize"`
Status string `json:"status"`
StatusMessage string `json:"statusMessage"`
HelmValues string `json:"helmValues"`
Endpoint string `json:"endpoint"`
AdminUser string `json:"adminUser"`
AdminPassword string `json:"adminPassword"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (d *LokiDeployment) BeforeCreate() {
if d.UUID == "" {
d.UUID = uuid.New().String()
}
}
+38
View File
@@ -0,0 +1,38 @@
package models
import "time"
type Host struct {
ID uint `json:"id"`
Name string `json:"name"`
HostIP string `json:"hostIP"`
Port int `json:"port"`
User string `json:"user"`
AuthType string `json:"authType"`
Password string `json:"password"`
PrivateKey string `json:"privateKey"`
KeyPhrase string `json:"keyPhrase"`
OS string `json:"os"`
Arch string `json:"arch"`
Status string `json:"status"`
Labels string `json:"labels"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (h *Host) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": h.ID,
"name": h.Name,
"hostIP": h.HostIP,
"port": h.Port,
"user": h.User,
"authType": h.AuthType,
"os": h.OS,
"arch": h.Arch,
"status": h.Status,
"labels": h.Labels,
"createdAt": h.CreatedAt,
"updatedAt": h.UpdatedAt,
}
}
+12
View File
@@ -0,0 +1,12 @@
package models
import "time"
type DeploymentLog struct {
ID uint `json:"id"`
DeploymentID uint `json:"deploymentID"`
Level string `json:"level"`
Message string `json:"message"`
Detail string `json:"detail"`
CreatedAt time.Time `json:"createdAt"`
}
+68
View File
@@ -0,0 +1,68 @@
package models
// Sanitizer interface for models that can sanitize themselves
type Sanitizer interface {
Sanitized() map[string]interface{}
}
// SanitizedJSON sanitizes a single model or a slice of models
func SanitizedJSON(v any) any {
switch val := v.(type) {
// Single objects
case User:
return val.Sanitized()
case *User:
if val == nil {
return nil
}
return val.Sanitized()
case Cluster:
return val.Sanitized()
case *Cluster:
if val == nil {
return nil
}
return val.Sanitized()
case Host:
return val.Sanitized()
case *Host:
if val == nil {
return nil
}
return val.Sanitized()
case StorageConfig:
return val.Sanitized()
case *StorageConfig:
if val == nil {
return nil
}
return val.Sanitized()
// Slices
case []User:
result := make([]map[string]interface{}, len(val))
for i, item := range val {
result[i] = item.Sanitized()
}
return result
case []Cluster:
result := make([]map[string]interface{}, len(val))
for i, item := range val {
result[i] = item.Sanitized()
}
return result
case []Host:
result := make([]map[string]interface{}, len(val))
for i, item := range val {
result[i] = item.Sanitized()
}
return result
case []StorageConfig:
result := make([]map[string]interface{}, len(val))
for i, item := range val {
result[i] = item.Sanitized()
}
return result
default:
return v
}
}
+7
View File
@@ -0,0 +1,7 @@
package models
type SystemSetting struct {
ID uint `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
}
+35
View File
@@ -0,0 +1,35 @@
package models
import "time"
type StorageConfig struct {
ID uint `json:"id"`
Name string `json:"name"`
StorageType string `json:"storageType"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
PathPrefix string `json:"pathPrefix"`
Insecure bool `json:"insecure"`
IsDefault bool `json:"isDefault"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (s *StorageConfig) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": s.ID,
"name": s.Name,
"storageType": s.StorageType,
"endpoint": s.Endpoint,
"bucket": s.Bucket,
"region": s.Region,
"pathPrefix": s.PathPrefix,
"insecure": s.Insecure,
"isDefault": s.IsDefault,
"createdAt": s.CreatedAt,
"updatedAt": s.UpdatedAt,
}
}
+24
View File
@@ -0,0 +1,24 @@
package models
import "time"
type User struct {
ID uint `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (u *User) Sanitized() map[string]interface{} {
return map[string]interface{}{
"id": u.ID,
"username": u.Username,
"nickname": u.Nickname,
"role": u.Role,
"createdAt": u.CreatedAt,
"updatedAt": u.UpdatedAt,
}
}
+127
View File
@@ -0,0 +1,127 @@
package routers
import (
"os"
"path/filepath"
"strings"
"lokideploy/controllers"
"lokideploy/middleware"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func SetupRouter() *gin.Engine {
r := gin.Default()
// CORS
cfg := cors.DefaultConfig()
cfg.AllowAllOrigins = true
cfg.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With"}
cfg.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}
r.Use(cors.New(cfg))
// Controllers
authCtrl := controllers.NewAuthController()
clusterCtrl := controllers.NewClusterController()
hostCtrl := controllers.NewHostController()
storageCtrl := controllers.NewStorageController()
deploymentCtrl := controllers.NewDeploymentController()
// API v1
api := r.Group("/api/v1")
// Public
api.POST("/auth/login", authCtrl.Login)
// Auth required
authorized := api.Group("")
authorized.Use(middleware.JWTAuth())
// Auth routes
authorized.GET("/auth/user", authCtrl.GetUser)
authorized.POST("/auth/password", authCtrl.ChangePassword)
authorized.POST("/auth/register", middleware.AdminAuth(), authCtrl.Register)
// Clusters
authorized.GET("/clusters", clusterCtrl.List)
authorized.GET("/clusters/:id", clusterCtrl.Get)
authorized.POST("/clusters", clusterCtrl.Create)
authorized.PUT("/clusters/:id", clusterCtrl.Update)
authorized.DELETE("/clusters/:id", clusterCtrl.Delete)
authorized.POST("/clusters/:id/validate", clusterCtrl.Validate)
authorized.GET("/clusters/:id/info", clusterCtrl.GetInfo)
// Hosts
authorized.GET("/hosts", hostCtrl.List)
authorized.GET("/hosts/:id", hostCtrl.Get)
authorized.POST("/hosts", hostCtrl.Create)
authorized.PUT("/hosts/:id", hostCtrl.Update)
authorized.DELETE("/hosts/:id", hostCtrl.Delete)
authorized.POST("/hosts/:id/validate", hostCtrl.Validate)
authorized.GET("/hosts/:id/info", hostCtrl.GetInfo)
authorized.POST("/hosts/:id/execute", hostCtrl.Execute)
// Storage
authorized.GET("/storages", storageCtrl.List)
authorized.GET("/storages/:id", storageCtrl.Get)
authorized.POST("/storages", storageCtrl.Create)
authorized.PUT("/storages/:id", storageCtrl.Update)
authorized.DELETE("/storages/:id", storageCtrl.Delete)
authorized.GET("/storages/default", storageCtrl.GetDefault)
// Deployments
authorized.GET("/deployments", deploymentCtrl.List)
authorized.GET("/deployments/:id", deploymentCtrl.Get)
authorized.POST("/deployments", deploymentCtrl.Create)
authorized.PUT("/deployments/:id", deploymentCtrl.Update)
authorized.DELETE("/deployments/:id", deploymentCtrl.Delete)
authorized.POST("/deployments/:id/deploy", deploymentCtrl.Deploy)
authorized.POST("/deployments/:id/stop", deploymentCtrl.Stop)
authorized.POST("/deployments/:id/restart", deploymentCtrl.Restart)
authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs)
authorized.GET("/deployments/stats", deploymentCtrl.GetStats)
// Static files - use executable directory as base
distPath := findDistPath()
if distPath != "" {
r.Static("/assets", filepath.Join(distPath, "assets"))
r.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(404, gin.H{"code": 404, "message": "API endpoint not found"})
return
}
c.File(filepath.Join(distPath, "index.html"))
})
}
return r
}
// findDistPath finds the frontend dist directory
func findDistPath() string {
// Try relative to executable
if ex, err := os.Executable(); err == nil {
execDir := filepath.Dir(ex)
// If running via "go run", executable is in temp dir - fall through
if !strings.Contains(execDir, "/tmp/go-build") {
for _, rel := range []string{"dist", "../dist", "../../dist"} {
p := filepath.Join(execDir, rel)
if _, err := os.Stat(filepath.Join(p, "index.html")); err == nil {
return p
}
}
}
}
// Try working directory relative paths
for _, p := range []string{"./dist", "../dist", "/app/dist"} {
if abs, err := filepath.Abs(p); err == nil {
if _, err := os.Stat(filepath.Join(abs, "index.html")); err == nil {
return abs
}
}
}
return ""
}
+23
View File
@@ -0,0 +1,23 @@
package seed
import (
"lokideploy/models"
"lokideploy/utils"
)
func Seed() {
db := models.GetDB()
db.RLock()
hasUsers := len(db.Users) > 0
db.RUnlock()
if !hasUsers {
hash, _ := utils.HashPassword("admin123")
models.GetDB().CreateUser(&models.User{
Username: "admin",
Password: hash,
Nickname: "Administrator",
Role: "admin",
})
}
}
+83
View File
@@ -0,0 +1,83 @@
package services
import (
"lokideploy/models"
"lokideploy/utils"
)
type AuthService struct{}
func NewAuthService() *AuthService {
return &AuthService{}
}
func (s *AuthService) Login(username, password string) (*models.User, string, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Users {
if db.Users[i].Username == username {
if !utils.VerifyPassword(password, db.Users[i].Password) {
return nil, "", utils.ErrInvalidCredentials
}
token, err := utils.GenerateToken(db.Users[i].ID, db.Users[i].Username, db.Users[i].Role)
if err != nil {
return nil, "", err
}
return &db.Users[i], token, nil
}
}
return nil, "", utils.ErrInvalidCredentials
}
func (s *AuthService) Register(username, password, nickname, role string) (*models.User, error) {
hash, err := utils.HashPassword(password)
if err != nil {
return nil, err
}
user := &models.User{
ID: models.GetDB().NextID("users"),
Username: username,
Password: hash,
Nickname: nickname,
Role: role,
}
if err := models.GetDB().CreateUser(user); err != nil {
return nil, err
}
return user, nil
}
func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Users {
if db.Users[i].ID == id {
u := db.Users[i]
return &u, nil
}
}
return nil, utils.ErrNotFound
}
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Users {
if db.Users[i].ID == userID {
if !utils.VerifyPassword(oldPassword, db.Users[i].Password) {
return utils.ErrInvalidCredentials
}
hash, err := utils.HashPassword(newPassword)
if err != nil {
return err
}
db.Users[i].Password = hash
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
+195
View File
@@ -0,0 +1,195 @@
package services
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"lokideploy/models"
"lokideploy/utils"
)
type ClusterService struct{}
func NewClusterService() *ClusterService {
return &ClusterService{}
}
func SaveKubeConfig(name, content string) (string, error) {
dir := filepath.Join("data", "kubeconfigs")
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
path := filepath.Join(dir, fmt.Sprintf("%s.yaml", name))
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return "", err
}
return path, nil
}
// Create 创建集群。token 接入时自动构造等价 kubeconfig,统一存储,使部署模块同样可用。
func (s *ClusterService) Create(name, description, configType, apiServer, kubeconfig, token, contextName string) (*models.Cluster, error) {
if configType == "" {
configType = "kubeconfig"
}
finalKubeconfig := kubeconfig
if configType == "token" {
finalKubeconfig = BuildKubeconfigFromToken(apiServer, token)
}
// 展示用 APIServer:token 模式取入参,kubeconfig 模式从内容提取
if apiServer == "" {
apiServer = ExtractAPIServer(finalKubeconfig)
}
path, err := SaveKubeConfig(name, finalKubeconfig)
if err != nil {
return nil, fmt.Errorf("kubeconfig 保存失败: %w", err)
}
cluster := models.Cluster{
ID: models.GetDB().NextID("clusters"),
Name: name,
Description: description,
ConfigType: configType,
APIServer: apiServer,
KubeConfig: finalKubeconfig,
KubeConfigPath: path,
Context: contextName,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
db := models.GetDB()
db.Lock()
db.Clusters = append(db.Clusters, cluster)
db.Unlock()
go db.Save()
go s.Validate(cluster.ID)
return &cluster, nil
}
func (s *ClusterService) Get(id uint) (*models.Cluster, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Clusters {
if db.Clusters[i].ID == id {
c := db.Clusters[i]
return &c, nil
}
}
return nil, utils.ErrNotFound
}
func (s *ClusterService) List(page, pageSize int) ([]models.Cluster, int64, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
total := int64(len(db.Clusters))
start := (page - 1) * pageSize
if start < 0 || start >= len(db.Clusters) {
return []models.Cluster{}, total, nil
}
end := start + pageSize
if end > len(db.Clusters) {
end = len(db.Clusters)
}
result := make([]models.Cluster, end-start)
copy(result, db.Clusters[start:end])
return result, total, nil
}
func (s *ClusterService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Clusters {
if db.Clusters[i].ID == id {
if v, ok := updates["name"].(string); ok && v != "" {
db.Clusters[i].Name = v
}
if v, ok := updates["description"].(string); ok {
db.Clusters[i].Description = v
}
if v, ok := updates["status"].(string); ok && v != "" {
db.Clusters[i].Status = v
}
if v, ok := updates["version"].(string); ok {
db.Clusters[i].Version = v
}
if v, ok := updates["nodesCount"].(int); ok {
db.Clusters[i].NodesCount = v
}
if v, ok := updates["context"].(string); ok {
db.Clusters[i].Context = v
}
if v, ok := updates["lastConnected"].(time.Time); ok {
db.Clusters[i].LastConnected = v
}
// 接入方式与展示字段
if v, ok := updates["configType"].(string); ok && v != "" {
db.Clusters[i].ConfigType = v
}
if v, ok := updates["apiServer"].(string); ok {
db.Clusters[i].APIServer = v
}
// 凭据字段:空值不覆盖,避免编辑其它项时清空
if v, ok := updates["kubeconfig"].(string); ok && v != "" {
db.Clusters[i].KubeConfig = v
if db.Clusters[i].KubeConfigPath != "" {
_ = os.WriteFile(db.Clusters[i].KubeConfigPath, []byte(v), 0600)
}
}
db.Clusters[i].UpdatedAt = time.Now()
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
func (s *ClusterService) Delete(id uint) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i, c := range db.Clusters {
if c.ID == id {
if c.KubeConfigPath != "" {
_ = os.Remove(c.KubeConfigPath)
}
db.Clusters = append(db.Clusters[:i], db.Clusters[i+1:]...)
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
func (s *ClusterService) Validate(id uint) {
cluster, err := s.Get(id)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
info, err := GetClusterInfo(ctx, cluster)
if err != nil {
s.Update(id, map[string]interface{}{"status": "error"})
return
}
s.Update(id, map[string]interface{}{
"status": "active",
"version": info.Version,
"nodesCount": len(info.Nodes),
"lastConnected": time.Now(),
"context": info.CurrentCtx,
})
}
+164
View File
@@ -0,0 +1,164 @@
package services
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"lokideploy/models"
)
type ClusterInfo struct {
Version string `json:"version"`
Nodes []NodeInfo `json:"nodes"`
Namespaces []string `json:"namespaces"`
Contexts []string `json:"contexts"`
CurrentCtx string `json:"currentContext"`
}
type NodeInfo struct {
Name string `json:"name"`
Status string `json:"status"`
Roles []string `json:"roles"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
InternalIP string `json:"internalIP"`
Labels map[string]string `json:"labels"`
}
// GetClusterInfo 通过 kubectl 查询集群信息。
// Token 接入的集群在创建时已构造等价 kubeconfig,因此 kubectl 同样适用。
func GetClusterInfo(ctx context.Context, cluster *models.Cluster) (*ClusterInfo, error) {
kubeconfig, err := resolveKubeconfig(cluster)
if err != nil {
return nil, err
}
kubeFile, err := os.CreateTemp("", "kube-*.yaml")
if err != nil {
return nil, err
}
defer os.Remove(kubeFile.Name())
kubeFile.WriteString(kubeconfig)
kubeFile.Close()
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
info := &ClusterInfo{
Nodes: []NodeInfo{},
Namespaces: []string{},
Contexts: []string{},
}
// Version(best-effort)
if versionOut, err := runCmd(ctx, env, "kubectl", "version", "-o", "json"); err == nil {
var versionData struct {
ServerVersion struct {
GitVersion string `json:"gitVersion"`
} `json:"serverVersion"`
}
json.Unmarshal(versionOut, &versionData)
info.Version = versionData.ServerVersion.GitVersion
}
// Nodes
nodesOut, err := runCmd(ctx, env, "kubectl", "get", "nodes", "-o", "json")
if err != nil {
return nil, fmt.Errorf("获取节点列表失败: %v", err)
}
var nodesData struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
Labels map[string]string `json:"labels"`
} `json:"metadata"`
Status struct {
Conditions []struct {
Type string `json:"type"`
Status string `json:"status"`
} `json:"conditions"`
NodeInfo struct {
KubeletVersion string `json:"kubeletVersion"`
OperatingSystem string `json:"operatingSystem"`
Architecture string `json:"architecture"`
} `json:"nodeInfo"`
Addresses []struct {
Type string `json:"type"`
Address string `json:"address"`
} `json:"addresses"`
} `json:"status"`
} `json:"items"`
}
json.Unmarshal(nodesOut, &nodesData)
for _, item := range nodesData.Items {
status := "Unknown"
for _, cond := range item.Status.Conditions {
if cond.Type == "Ready" {
if cond.Status == "True" {
status = "Ready"
} else {
status = "NotReady"
}
break
}
}
var roles []string
for label := range item.Metadata.Labels {
if strings.HasPrefix(label, "node-role.kubernetes.io/") {
roles = append(roles, label[len("node-role.kubernetes.io/"):])
}
}
if len(roles) == 0 {
roles = []string{"worker"}
}
internalIP := ""
for _, addr := range item.Status.Addresses {
if addr.Type == "InternalIP" {
internalIP = addr.Address
break
}
}
info.Nodes = append(info.Nodes, NodeInfo{
Name: item.Metadata.Name,
Status: status,
Roles: roles,
Version: item.Status.NodeInfo.KubeletVersion,
OS: item.Status.NodeInfo.OperatingSystem,
Arch: item.Status.NodeInfo.Architecture,
InternalIP: internalIP,
Labels: item.Metadata.Labels,
})
}
// Namespaces(best-effort)
if nsOut, err := runCmd(ctx, env, "kubectl", "get", "ns", "-o", "jsonpath={.items[*].metadata.name}"); err == nil {
info.Namespaces = strings.Fields(string(nsOut))
}
// Contexts(best-effort)
if ctxOut, err := runCmd(ctx, env, "kubectl", "config", "get-contexts", "-o", "name"); err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(ctxOut)), "\n") {
if line = strings.TrimSpace(line); line != "" {
info.Contexts = append(info.Contexts, line)
}
}
}
// Current context(best-effort)
if currentCtxOut, err := runCmd(ctx, env, "kubectl", "config", "current-context"); err == nil {
info.CurrentCtx = strings.TrimSpace(string(currentCtxOut))
}
return info, nil
}
func runCmd(ctx context.Context, env []string, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = env
return cmd.CombinedOutput()
}
+347
View File
@@ -0,0 +1,347 @@
package services
import (
"context"
"fmt"
"time"
"lokideploy/models"
"lokideploy/utils"
)
type DeploymentService struct{}
func NewDeploymentService() *DeploymentService {
return &DeploymentService{}
}
type CreateDeploymentRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Version string `json:"version"`
Mode string `json:"mode" binding:"required"`
ClusterID *uint `json:"clusterID"`
HostID *uint `json:"hostID"`
Namespace string `json:"namespace"`
StorageConfigID uint `json:"storageConfigID"`
AuthEnabled bool `json:"authEnabled"`
RetentionDays int `json:"retentionDays"`
Replicas int `json:"replicas"`
ResourcesCPU string `json:"resourcesCPU"`
ResourcesMem string `json:"resourcesMem"`
StorageSize string `json:"storageSize"`
}
func (s *DeploymentService) Create(req *CreateDeploymentRequest) (*models.LokiDeployment, error) {
db := models.GetDB()
d := models.LokiDeployment{
ID: db.NextID("deployments"),
Name: req.Name,
Description: req.Description,
Version: req.Version,
Mode: req.Mode,
ClusterID: req.ClusterID,
HostID: req.HostID,
Namespace: req.Namespace,
StorageConfigID: req.StorageConfigID,
AuthEnabled: req.AuthEnabled,
RetentionDays: req.RetentionDays,
Replicas: req.Replicas,
ResourcesCPU: req.ResourcesCPU,
ResourcesMem: req.ResourcesMem,
StorageSize: req.StorageSize,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
d.BeforeCreate()
// Load relations
if d.ClusterID != nil && *d.ClusterID > 0 {
for i := range db.Clusters {
if db.Clusters[i].ID == *d.ClusterID {
c := db.Clusters[i]
d.Cluster = &c
break
}
}
}
if d.HostID != nil && *d.HostID > 0 {
for i := range db.Hosts {
if db.Hosts[i].ID == *d.HostID {
h := db.Hosts[i]
d.Host = &h
break
}
}
}
if d.StorageConfigID > 0 {
for i := range db.Storages {
if db.Storages[i].ID == d.StorageConfigID {
st := db.Storages[i]
d.StorageConfig = &st
break
}
}
}
db.Lock()
db.Deployments = append(db.Deployments, d)
db.Unlock()
go db.Save()
return &d, nil
}
func (s *DeploymentService) Get(id uint) (*models.LokiDeployment, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Deployments {
if db.Deployments[i].ID == id {
d := db.Deployments[i]
s.loadRelations(&d)
return &d, nil
}
}
return nil, utils.ErrNotFound
}
func (s *DeploymentService) List(page, pageSize int) ([]models.LokiDeployment, int64, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
total := int64(len(db.Deployments))
start := (page - 1) * pageSize
if start < 0 || start >= len(db.Deployments) {
return []models.LokiDeployment{}, total, nil
}
end := start + pageSize
if end > len(db.Deployments) {
end = len(db.Deployments)
}
result := make([]models.LokiDeployment, end-start)
copy(result, db.Deployments[start:end])
for i := range result {
s.loadRelations(&result[i])
}
return result, total, nil
}
func (s *DeploymentService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Deployments {
if db.Deployments[i].ID == id {
if v, ok := updates["status"].(string); ok {
db.Deployments[i].Status = v
}
if v, ok := updates["statusMessage"].(string); ok {
db.Deployments[i].StatusMessage = v
}
if v, ok := updates["endpoint"].(string); ok {
db.Deployments[i].Endpoint = v
}
db.Deployments[i].UpdatedAt = time.Now()
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
func (s *DeploymentService) Delete(id uint) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i, d := range db.Deployments {
if d.ID == id {
db.Deployments = append(db.Deployments[:i], db.Deployments[i+1:]...)
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
func (s *DeploymentService) StartDeploy(id uint) error {
deployment, err := s.Get(id)
if err != nil {
return err
}
if deployment.Status == "deploying" {
return fmt.Errorf("部署正在进行中,请勿重复操作")
}
s.Update(id, map[string]interface{}{"status": "deploying"})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
logFunc := func(level, message, detail string) {
s.AddLog(id, level, message, detail)
}
deployer := NewLokiDeployer(deployment, logFunc)
go func() {
err := deployer.Deploy(ctx)
if err != nil {
s.Update(id, map[string]interface{}{
"status": "failed",
"statusMessage": fmt.Sprintf("Deployment failed: %v", err),
})
s.AddLog(id, "error", fmt.Sprintf("Deployment failed: %v", err), "")
} else {
s.Update(id, map[string]interface{}{
"status": "running",
"statusMessage": "Deployment completed successfully",
"endpoint": s.getEndpoint(deployment),
})
s.AddLog(id, "success", "Deployment completed successfully", "")
}
}()
return nil
}
func (s *DeploymentService) StopDeployment(id uint) error {
deployment, err := s.Get(id)
if err != nil {
return err
}
if deployment.Status != "running" && deployment.Status != "deploying" {
return fmt.Errorf("部署未在运行,无法停止")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
CleanupLoki(ctx, deployment)
return s.Update(id, map[string]interface{}{
"status": "stopped",
"statusMessage": "Deployment stopped by user",
})
}
func (s *DeploymentService) RestartDeployment(id uint) error {
if err := s.StopDeployment(id); err != nil {
return err
}
time.Sleep(2 * time.Second)
return s.StartDeploy(id)
}
func (s *DeploymentService) AddLog(deploymentID uint, level, message, detail string) {
db := models.GetDB()
log := models.DeploymentLog{
ID: db.NextID("logs"),
DeploymentID: deploymentID,
Level: level,
Message: message,
Detail: detail,
CreatedAt: time.Now(),
}
db.Lock()
db.Logs = append(db.Logs, log)
db.Unlock()
go db.Save()
}
func (s *DeploymentService) GetLogs(deploymentID uint, page, pageSize int) ([]models.DeploymentLog, int64, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
var filtered []models.DeploymentLog
for i := len(db.Logs) - 1; i >= 0; i-- {
if db.Logs[i].DeploymentID == deploymentID {
filtered = append(filtered, db.Logs[i])
}
}
total := int64(len(filtered))
start := (page - 1) * pageSize
if start >= len(filtered) {
return []models.DeploymentLog{}, total, nil
}
end := start + pageSize
if end > len(filtered) {
end = len(filtered)
}
return filtered[start:end], total, nil
}
func (s *DeploymentService) GetStats() (map[string]interface{}, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
var running, failed, pending, stopped int64
for _, d := range db.Deployments {
switch d.Status {
case "running":
running++
case "failed":
failed++
case "pending":
pending++
case "stopped":
stopped++
}
}
return map[string]interface{}{
"totalDeployments": int64(len(db.Deployments)),
"running": running,
"failed": failed,
"pending": pending,
"stopped": stopped,
"clusterCount": int64(len(db.Clusters)),
"hostCount": int64(len(db.Hosts)),
}, nil
}
func (s *DeploymentService) loadRelations(d *models.LokiDeployment) {
db := models.GetDB()
if d.ClusterID != nil && *d.ClusterID > 0 {
for i := range db.Clusters {
if db.Clusters[i].ID == *d.ClusterID {
c := db.Clusters[i]
d.Cluster = &c
break
}
}
}
if d.HostID != nil && *d.HostID > 0 {
for i := range db.Hosts {
if db.Hosts[i].ID == *d.HostID {
h := db.Hosts[i]
d.Host = &h
break
}
}
}
if d.StorageConfigID > 0 {
for i := range db.Storages {
if db.Storages[i].ID == d.StorageConfigID {
st := db.Storages[i]
d.StorageConfig = &st
break
}
}
}
}
func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
switch d.Mode {
case "k8s-helm", "k8s-yaml":
ns := d.Namespace
if ns == "" {
ns = "loki"
}
return fmt.Sprintf("http://loki.%s.svc.cluster.local:3100", ns)
case "host-binary":
if d.Host != nil {
return fmt.Sprintf("http://%s:3100", d.Host.HostIP)
}
}
return ""
}
+177
View File
@@ -0,0 +1,177 @@
package services
import (
"context"
"strings"
"time"
"lokideploy/models"
"lokideploy/utils"
)
type HostService struct{}
func NewHostService() *HostService {
return &HostService{}
}
func (s *HostService) Create(name, hostIP string, port int, user, authType, password, privateKey, keyPhrase, os, arch, labels string) (*models.Host, error) {
if port == 0 {
port = 22
}
host := models.Host{
ID: models.GetDB().NextID("hosts"),
Name: name,
HostIP: hostIP,
Port: port,
User: user,
AuthType: authType,
OS: os,
Arch: arch,
Labels: labels,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if password != "" {
enc, _ := utils.Encrypt(password)
host.Password = "ENC:" + enc
}
if privateKey != "" {
enc, _ := utils.Encrypt(privateKey)
host.PrivateKey = "ENC:" + enc
}
if keyPhrase != "" {
enc, _ := utils.Encrypt(keyPhrase)
host.KeyPhrase = "ENC:" + enc
}
db := models.GetDB()
db.Lock()
db.Hosts = append(db.Hosts, host)
db.Unlock()
go db.Save()
go s.Validate(host.ID)
return &host, nil
}
func (s *HostService) Get(id uint) (*models.Host, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Hosts {
if db.Hosts[i].ID == id {
h := db.Hosts[i]
return &h, nil
}
}
return nil, utils.ErrNotFound
}
func (s *HostService) List(page, pageSize int) ([]models.Host, int64, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
total := int64(len(db.Hosts))
start := (page - 1) * pageSize
if start < 0 || start >= len(db.Hosts) {
return []models.Host{}, total, nil
}
end := start + pageSize
if end > len(db.Hosts) {
end = len(db.Hosts)
}
result := make([]models.Host, end-start)
copy(result, db.Hosts[start:end])
return result, total, nil
}
func (s *HostService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Hosts {
if db.Hosts[i].ID == id {
if v, ok := updates["name"].(string); ok {
db.Hosts[i].Name = v
}
if v, ok := updates["status"].(string); ok {
db.Hosts[i].Status = v
}
if v, ok := updates["os"].(string); ok {
db.Hosts[i].OS = v
}
if v, ok := updates["arch"].(string); ok {
db.Hosts[i].Arch = v
}
db.Hosts[i].UpdatedAt = time.Now()
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
func (s *HostService) Delete(id uint) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i, h := range db.Hosts {
if h.ID == id {
db.Hosts = append(db.Hosts[:i], db.Hosts[i+1:]...)
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
func (s *HostService) Validate(id uint) {
host, err := s.Get(id)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = utils.Decrypt(password[4:])
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
if err != nil {
s.Update(id, map[string]interface{}{"status": "error"})
return
}
defer client.Close()
if info, err := client.GetHostInfo(ctx); err == nil {
s.Update(id, map[string]interface{}{"status": "active", "os": info.OS, "arch": info.Arch})
} else {
s.Update(id, map[string]interface{}{"status": "active"})
}
}
func (s *HostService) ExecuteCommand(id uint, command string) (string, string, error) {
host, err := s.Get(id)
if err != nil {
return "", "", err
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = utils.Decrypt(password[4:])
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
if err != nil {
return "", "", err
}
defer client.Close()
return client.Execute(ctx, command)
}
+58
View File
@@ -0,0 +1,58 @@
package services
import (
"fmt"
"os"
"regexp"
"lokideploy/models"
)
// resolveKubeconfig 取集群最终的 kubeconfig 文本(优先内存字段,其次磁盘文件)
func resolveKubeconfig(cluster *models.Cluster) (string, error) {
if cluster.KubeConfig != "" {
return cluster.KubeConfig, nil
}
if cluster.KubeConfigPath != "" {
data, err := os.ReadFile(cluster.KubeConfigPath)
if err != nil {
return "", fmt.Errorf("读取 kubeconfig 文件失败: %v", err)
}
return string(data), nil
}
return "", fmt.Errorf("集群未配置 kubeconfig")
}
// BuildKubeconfigFromToken 用 API Server + Token 构造最小 kubeconfig
// 采用 insecure-skip-tls-verify,便于内网/自签证书集群接入
func BuildKubeconfigFromToken(apiServer, token string) string {
return fmt.Sprintf(`apiVersion: v1
kind: Config
clusters:
- cluster:
insecure-skip-tls-verify: true
server: %s
name: loki-cluster
contexts:
- context:
cluster: loki-cluster
user: loki-user
name: loki-context
current-context: loki-context
users:
- name: loki-user
user:
token: %s
`, apiServer, token)
}
var apiServerRe = regexp.MustCompile(`(?m)^\s*server:\s*(\S+)`)
// ExtractAPIServer 从 kubeconfig 文本提取 server 地址(展示用)
func ExtractAPIServer(kubeconfig string) string {
m := apiServerRe.FindStringSubmatch(kubeconfig)
if len(m) >= 2 {
return m[1]
}
return ""
}
+287
View File
@@ -0,0 +1,287 @@
package services
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"lokideploy/models"
"lokideploy/utils"
)
type LokiDeployer struct {
deployment *models.LokiDeployment
logFunc func(level, message, detail string)
}
func NewLokiDeployer(deployment *models.LokiDeployment, logFunc func(level, message, detail string)) *LokiDeployer {
return &LokiDeployer{deployment: deployment, logFunc: logFunc}
}
func (d *LokiDeployer) Deploy(ctx context.Context) error {
d.log("info", fmt.Sprintf("Starting Loki deployment: %s (mode: %s)", d.deployment.Name, d.deployment.Mode), "")
switch d.deployment.Mode {
case "k8s-helm":
return d.deployHelm(ctx)
case "k8s-yaml":
return d.deployYaml(ctx)
case "host-binary":
return d.deployBinary(ctx)
default:
return fmt.Errorf("不支持的部署模式: %s", d.deployment.Mode)
}
}
func (d *LokiDeployer) deployHelm(ctx context.Context) error {
if d.deployment.Cluster == nil {
return fmt.Errorf("请选择目标集群")
}
kubeconfig := d.deployment.Cluster.KubeConfig
if kubeconfig == "" && d.deployment.Cluster.KubeConfigPath != "" {
data, _ := os.ReadFile(d.deployment.Cluster.KubeConfigPath)
kubeconfig = string(data)
}
kubeFile, _ := os.CreateTemp("", "kube-*.yaml")
defer os.Remove(kubeFile.Name())
kubeFile.WriteString(kubeconfig)
kubeFile.Close()
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
ns := d.deployment.Namespace
if ns == "" {
ns = "loki"
}
d.log("info", fmt.Sprintf("Creating namespace %s...", ns), "")
exec.CommandContext(ctx, "kubectl", "create", "namespace", ns, "--dry-run=client", "-o", "yaml").CombinedOutput()
d.log("info", "Adding Grafana Helm repo...", "")
exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts").CombinedOutput()
exec.CommandContext(ctx, "helm", "repo", "update").CombinedOutput()
d.log("info", fmt.Sprintf("Installing Loki in %s...", ns), "")
releaseName := d.deployment.Name
if releaseName == "" {
releaseName = "loki"
}
chart := "grafana/loki"
if d.deployment.Replicas > 1 {
chart = "grafana/loki-distributed"
}
cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", releaseName, chart,
"--namespace", ns, "--create-namespace", "--wait", "--timeout", "10m")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
d.log("error", fmt.Sprintf("Helm install failed: %v", err), string(out))
return err
}
d.log("success", "Loki installed via Helm", string(out))
return nil
}
func (d *LokiDeployer) deployYaml(ctx context.Context) error {
if d.deployment.Cluster == nil {
return fmt.Errorf("请选择目标集群")
}
kubeconfig := d.deployment.Cluster.KubeConfig
if kubeconfig == "" && d.deployment.Cluster.KubeConfigPath != "" {
data, _ := os.ReadFile(d.deployment.Cluster.KubeConfigPath)
kubeconfig = string(data)
}
kubeFile, _ := os.CreateTemp("", "kube-*.yaml")
defer os.Remove(kubeFile.Name())
kubeFile.WriteString(kubeconfig)
kubeFile.Close()
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
ns := d.deployment.Namespace
if ns == "" {
ns = "loki"
}
manifest := d.generateManifest(ns)
manifestFile, _ := os.CreateTemp("", "loki-*.yaml")
defer os.Remove(manifestFile.Name())
manifestFile.WriteString(manifest)
manifestFile.Close()
cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestFile.Name(), "-n", ns)
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
d.log("error", fmt.Sprintf("kubectl apply failed: %v", err), string(out))
return err
}
d.log("success", "Loki deployed via YAML", string(out))
return nil
}
func (d *LokiDeployer) deployBinary(ctx context.Context) error {
if d.deployment.Host == nil {
return fmt.Errorf("请选择目标主机")
}
host := d.deployment.Host
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = DecryptCred(password)
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
if err != nil {
return err
}
defer client.Close()
d.log("info", "SSH connected, downloading Loki...", "")
arch, _, _ := client.Execute(ctx, "uname -m")
lokiArch := strings.TrimSpace(arch)
switch lokiArch {
case "x86_64":
lokiArch = "amd64"
case "aarch64":
lokiArch = "arm64"
}
version := d.deployment.Version
if version == "" {
version = "3.0.0"
}
client.Execute(ctx, "mkdir -p /opt/loki /var/lib/loki /etc/loki")
downloadURL := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/loki-linux-%s.zip", version, lokiArch)
cmd := fmt.Sprintf("cd /tmp && curl -sL -o loki.zip '%s' && unzip -o loki.zip -d /opt/loki/ && chmod +x /opt/loki/loki-linux-%s", downloadURL, lokiArch)
client.Execute(ctx, cmd)
client.Execute(ctx, "ln -sf /opt/loki/loki-linux-* /usr/local/bin/loki 2>/dev/null || true")
// Upload config
config := d.generateLokiConfig()
client.Execute(ctx, fmt.Sprintf("cat > /etc/loki/loki.yaml << 'EOF'\n%s\nEOF", config))
// Systemd service
service := `[Unit]
Description=Grafana Loki
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/loki -config.file=/etc/loki/loki.yaml
Restart=always
[Install]
WantedBy=multi-user.target
`
client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/loki.service << 'EOF'\n%s\nEOF", service))
client.Execute(ctx, "systemctl daemon-reload && systemctl enable loki && systemctl restart loki")
d.log("success", fmt.Sprintf("Loki deployed on %s", host.HostIP), "")
return nil
}
func (d *LokiDeployer) generateManifest(ns string) string {
return fmt.Sprintf(`apiVersion: v1
kind: Namespace
metadata:
name: %s
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: loki
namespace: %s
spec:
replicas: 1
selector:
matchLabels:
app: loki
template:
metadata:
labels:
app: loki
spec:
containers:
- name: loki
image: grafana/loki:%s
ports:
- containerPort: 3100
---
apiVersion: v1
kind: Service
metadata:
name: loki
namespace: %s
spec:
ports:
- port: 3100
selector:
app: loki
`, ns, ns, d.deployment.Version, ns)
}
func (d *LokiDeployer) generateLokiConfig() string {
return fmt.Sprintf(`auth_enabled: false
server:
http_listen_port: 3100
limits_config:
retention_period: %dh
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
`, d.deployment.RetentionDays*24)
}
func (d *LokiDeployer) log(level, message, detail string) {
if d.logFunc != nil {
d.logFunc(level, message, detail)
}
}
func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) {
if deployment.Mode == "k8s-helm" && deployment.Cluster != nil {
kubeconfig := deployment.Cluster.KubeConfig
if kubeconfig == "" && deployment.Cluster.KubeConfigPath != "" {
data, _ := os.ReadFile(deployment.Cluster.KubeConfigPath)
kubeconfig = string(data)
}
kubeFile, _ := os.CreateTemp("", "kube-*.yaml")
defer os.Remove(kubeFile.Name())
os.WriteFile(kubeFile.Name(), []byte(kubeconfig), 0600)
env := append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
cmd := exec.CommandContext(ctx, "helm", "uninstall", deployment.Name, "--namespace", deployment.Namespace)
cmd.Env = env
cmd.CombinedOutput()
}
if deployment.Mode == "host-binary" && deployment.Host != nil {
host := deployment.Host
password := host.Password
if strings.HasPrefix(password, "ENC:") {
password, _ = DecryptCred(password)
}
client, _ := NewSSHClient(host.HostIP, host.Port, host.User, password)
if client != nil {
defer client.Close()
client.Execute(ctx, "systemctl stop loki && systemctl disable loki && rm -f /etc/systemd/system/loki.service /usr/local/bin/loki")
}
}
}
func DecryptCred(encrypted string) (string, error) {
if strings.HasPrefix(encrypted, "ENC:") {
return utils.Decrypt(encrypted[4:])
}
return encrypted, nil
}
+168
View File
@@ -0,0 +1,168 @@
package services
import (
"bytes"
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
type SSHClient struct {
client *ssh.Client
}
type HostInfo struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Version string `json:"version"`
Arch string `json:"arch"`
Kernel string `json:"kernel"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
Disk string `json:"disk"`
Uptime string `json:"uptime"`
// 资源使用率(百分比)
CPUUsage float64 `json:"cpuUsage"`
MemUsage float64 `json:"memUsage"`
DiskUsage float64 `json:"diskUsage"`
// 资源已用/总量
MemTotal string `json:"memTotal"`
MemUsed string `json:"memUsed"`
DiskTotal string `json:"diskTotal"`
DiskUsed string `json:"diskUsed"`
}
func NewSSHClient(host string, port int, user, password string) (*SSHClient, error) {
if port == 0 {
port = 22
}
config := &ssh.ClientConfig{
User: user,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
Auth: []ssh.AuthMethod{ssh.Password(password)},
}
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), config)
if err != nil {
return nil, errors.New(FormatSSHError(err))
}
return &SSHClient{client: client}, nil
}
func (c *SSHClient) Execute(ctx context.Context, command string) (string, string, error) {
session, err := c.client.NewSession()
if err != nil {
return "", "", err
}
defer session.Close()
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
done := make(chan error, 1)
go func() { done <- session.Run(command) }()
select {
case err := <-done:
return stdout.String(), stderr.String(), err
case <-ctx.Done():
session.Signal(ssh.SIGKILL)
return "", "", ctx.Err()
}
}
// GetHostInfo 采集主机静态信息与资源使用率
func (c *SSHClient) GetHostInfo(ctx context.Context) (*HostInfo, error) {
info := &HostInfo{}
// 静态信息(逐条,best-effort)
if out, _, err := c.Execute(ctx, "hostname"); err == nil {
info.Hostname = strings.TrimSpace(out)
}
if out, _, err := c.Execute(ctx, `cat /etc/os-release | grep '^ID=' | cut -d= -f2 | tr -d '"'`); err == nil {
info.OS = strings.TrimSpace(out)
}
if out, _, err := c.Execute(ctx, `cat /etc/os-release | grep 'VERSION_ID=' | cut -d= -f2 | tr -d '"'`); err == nil {
info.Version = strings.TrimSpace(out)
}
if out, _, err := c.Execute(ctx, "uname -m"); err == nil {
info.Arch = strings.TrimSpace(out)
}
if out, _, err := c.Execute(ctx, "uname -r"); err == nil {
info.Kernel = strings.TrimSpace(out)
}
if out, _, err := c.Execute(ctx, "nproc"); err == nil {
info.CPU = strings.TrimSpace(out) + " 核"
}
if out, _, err := c.Execute(ctx, "free -h | awk '/^Mem:/ {print $2}'"); err == nil {
info.Memory = strings.TrimSpace(out)
}
if out, _, err := c.Execute(ctx, "df -h / | awk 'NR==2 {print $2}'"); err == nil {
info.Disk = strings.TrimSpace(out)
}
if out, _, err := c.Execute(ctx, "uptime -p 2>/dev/null || uptime"); err == nil {
info.Uptime = strings.TrimSpace(out)
}
// 资源使用率:一次合并命令(减少 SSH 往返)
// 输出形如:CPUUSAGE:12.3 / MEM:16384:8192 / DISK:51200:25600:50%
metricScript := `top -bn1 2>/dev/null | awk '/Cpu\(s\)/{print "CPUUSAGE:"100-$8; exit}'
free -m | awk '/^Mem:/{print "MEM:"$2":"$3}'
df -m / | awk 'NR==2{print "DISK:"$2":"$3":"$5}'`
if out, _, err := c.Execute(ctx, metricScript); err == nil {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "CPUUSAGE:"):
info.CPUUsage, _ = strconv.ParseFloat(strings.TrimPrefix(line, "CPUUSAGE:"), 64)
case strings.HasPrefix(line, "MEM:"):
parts := strings.Split(strings.TrimPrefix(line, "MEM:"), ":")
if len(parts) == 2 {
total, _ := strconv.Atoi(parts[0])
used, _ := strconv.Atoi(parts[1])
info.MemTotal = fmt.Sprintf("%d MB", total)
info.MemUsed = fmt.Sprintf("%d MB", used)
if total > 0 {
info.MemUsage = float64(used) / float64(total) * 100
}
}
case strings.HasPrefix(line, "DISK:"):
parts := strings.Split(strings.TrimPrefix(line, "DISK:"), ":")
if len(parts) >= 3 {
info.DiskTotal = parts[0] + " MB"
info.DiskUsed = parts[1] + " MB"
pct := strings.TrimSuffix(strings.TrimSpace(parts[2]), "%")
info.DiskUsage, _ = strconv.ParseFloat(pct, 64)
}
}
}
}
return info, nil
}
func (c *SSHClient) Close() error {
if c.client != nil {
return c.client.Close()
}
return nil
}
func TestSSHConnection(host string, port int, user, password string) error {
client, err := NewSSHClient(host, port, user, password)
if err != nil {
return err
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err = client.Execute(ctx, "echo 'connected'")
return err
}
+33
View File
@@ -0,0 +1,33 @@
package services
import "strings"
// FormatSSHError 将底层 SSH 错误翻译为中文友好提示,便于用户排查
func FormatSSHError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "i/o timeout") ||
strings.Contains(lower, "deadline exceeded") ||
strings.Contains(lower, "connection timed out"):
return "连接超时,请检查主机网络或 SSH 端口是否可达"
case strings.Contains(lower, "connection refused"):
return "连接被拒绝,目标端口未开放或 SSH 服务未运行"
case strings.Contains(lower, "no such host") ||
strings.Contains(lower, "no route to host"):
return "无法解析或到达目标主机,请检查 IP 地址"
case strings.Contains(lower, "permission denied") ||
strings.Contains(lower, "unable to authenticate") ||
strings.Contains(lower, "handshake") ||
strings.Contains(lower, "password"):
return "认证失败,请检查用户名、密码或 SSH 密钥"
case strings.Contains(lower, "no supported methods"):
return "SSH 协商失败,服务端未支持当前认证方式"
default:
return msg
}
}
+143
View File
@@ -0,0 +1,143 @@
package services
import (
"time"
"lokideploy/models"
"lokideploy/utils"
)
type StorageService struct{}
func NewStorageService() *StorageService {
return &StorageService{}
}
func (s *StorageService) Create(name, storageType, endpoint, bucket, region, accessKey, secretKey, pathPrefix string, insecure, isDefault bool) (*models.StorageConfig, error) {
encAccessKey, _ := utils.Encrypt(accessKey)
encSecretKey, _ := utils.Encrypt(secretKey)
storage := models.StorageConfig{
ID: models.GetDB().NextID("storages"),
Name: name,
StorageType: storageType,
Endpoint: endpoint,
Bucket: bucket,
Region: region,
AccessKey: "ENC:" + encAccessKey,
SecretKey: "ENC:" + encSecretKey,
PathPrefix: pathPrefix,
Insecure: insecure,
IsDefault: isDefault,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
db := models.GetDB()
if isDefault {
db.Lock()
for i := range db.Storages {
db.Storages[i].IsDefault = false
}
db.Unlock()
}
db.Lock()
db.Storages = append(db.Storages, storage)
db.Unlock()
go db.Save()
return &storage, nil
}
func (s *StorageService) Get(id uint) (*models.StorageConfig, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Storages {
if db.Storages[i].ID == id {
st := db.Storages[i]
return &st, nil
}
}
return nil, utils.ErrNotFound
}
func (s *StorageService) List(page, pageSize int) ([]models.StorageConfig, int64, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
total := int64(len(db.Storages))
start := (page - 1) * pageSize
if start < 0 || start >= len(db.Storages) {
return []models.StorageConfig{}, total, nil
}
end := start + pageSize
if end > len(db.Storages) {
end = len(db.Storages)
}
result := make([]models.StorageConfig, end-start)
copy(result, db.Storages[start:end])
return result, total, nil
}
func (s *StorageService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Storages {
if db.Storages[i].ID == id {
if v, ok := updates["name"].(string); ok {
db.Storages[i].Name = v
}
if v, ok := updates["endpoint"].(string); ok {
db.Storages[i].Endpoint = v
}
if v, ok := updates["bucket"].(string); ok {
db.Storages[i].Bucket = v
}
if v, ok := updates["region"].(string); ok {
db.Storages[i].Region = v
}
if v, ok := updates["isDefault"].(bool); ok {
if v {
for j := range db.Storages {
db.Storages[j].IsDefault = false
}
}
db.Storages[i].IsDefault = v
}
db.Storages[i].UpdatedAt = time.Now()
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
func (s *StorageService) Delete(id uint) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i, st := range db.Storages {
if st.ID == id {
db.Storages = append(db.Storages[:i], db.Storages[i+1:]...)
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
func (s *StorageService) GetDefault() (*models.StorageConfig, error) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Storages {
if db.Storages[i].IsDefault {
st := db.Storages[i]
return &st, nil
}
}
return nil, utils.ErrNotFound
}
+79
View File
@@ -0,0 +1,79 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"io"
"lokideploy/config"
"golang.org/x/crypto/argon2"
)
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
combined := append(salt, hash...)
return base64.StdEncoding.EncodeToString(combined), nil
}
func VerifyPassword(password, hashStr string) bool {
combined, err := base64.StdEncoding.DecodeString(hashStr)
if err != nil || len(combined) < 16 {
return false
}
salt := combined[:16]
hash := combined[16:]
newHash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(hash, newHash) == 1
}
func Encrypt(plaintext string) (string, error) {
key := []byte(config.GetConfig().JWTSecret)[:32]
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func Decrypt(ciphertext string) (string, error) {
key := []byte(config.GetConfig().JWTSecret)[:32]
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(data) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertextBytes := data[:gcm.NonceSize()], data[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
+11
View File
@@ -0,0 +1,11 @@
package utils
import "errors"
var (
ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrNotFound = errors.New("资源不存在")
ErrAlreadyExists = errors.New("资源已存在")
ErrUnauthorized = errors.New("未登录或登录已过期")
ErrForbidden = errors.New("没有操作权限")
)
+62
View File
@@ -0,0 +1,62 @@
package utils
import (
"errors"
"time"
"lokideploy/config"
"github.com/golang-jwt/jwt/v5"
"github.com/gin-gonic/gin"
)
type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint, username, role string) (string, error) {
cfg := config.GetConfig()
claims := Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: username,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func ParseToken(tokenString string) (*Claims, error) {
cfg := config.GetConfig()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JWTSecret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
func GetUserFromContext(c *gin.Context) (*Claims, bool) {
user, exists := c.Get("user")
if !exists {
return nil, false
}
claims, ok := user.(*Claims)
return claims, ok
}
func IsAdmin(c *gin.Context) bool {
claims, ok := GetUserFromContext(c)
return ok && claims.Role == "admin"
}
+81
View File
@@ -0,0 +1,81 @@
package utils
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
type PageData struct {
List interface{} `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{Code: 0, Message: "success", Data: data})
}
func Error(c *gin.Context, code int, message string) {
c.JSON(http.StatusOK, Response{Code: code, Message: message})
}
func BadRequest(c *gin.Context, message string) {
Error(c, 400, message)
}
func Unauthorized(c *gin.Context, message string) {
if message == "" {
message = "未登录或登录已过期"
}
c.JSON(http.StatusUnauthorized, Response{Code: 401, Message: message})
}
func Forbidden(c *gin.Context, message string) {
if message == "" {
message = "没有操作权限"
}
c.JSON(http.StatusForbidden, Response{Code: 403, Message: message})
}
func NotFound(c *gin.Context, message string) {
if message == "" {
message = "资源不存在"
}
Error(c, 404, message)
}
func Pagination(c *gin.Context) (page, pageSize int) {
page, pageSize = 1, 10
if p := c.Query("page"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v > 0 {
page = v
}
}
if ps := c.Query("pageSize"); ps != "" {
if v, err := strconv.Atoi(ps); err == nil && v > 0 && v <= 100 {
pageSize = v
}
}
return
}
func IntParam(c *gin.Context, key string) (int, error) {
return strconv.Atoi(c.Param(key))
}
func BindAndValidate(c *gin.Context, obj interface{}) bool {
if err := c.ShouldBindJSON(obj); err != nil {
BadRequest(c, "请求参数无效: "+err.Error())
return false
}
return true
}