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:
commit
2a93de600d
134 files changed
+19595
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user