feat: wire delivery result and infra data backends
This commit is contained in:
@@ -47,9 +47,38 @@ type ContainerWorkload struct {
|
||||
StatusClass string `json:"statusClass"`
|
||||
}
|
||||
|
||||
type ContainerCluster struct {
|
||||
Name string `json:"name"`
|
||||
Zone string `json:"zone"`
|
||||
ZoneClass string `json:"zoneClass"`
|
||||
Status string `json:"status"`
|
||||
StatusClass string `json:"statusClass"`
|
||||
Nodes int `json:"nodes"`
|
||||
ReadyNodes int `json:"readyNodes"`
|
||||
CPU int `json:"cpu"`
|
||||
CPUClass string `json:"cpuClass"`
|
||||
Version string `json:"version"`
|
||||
Calico string `json:"calico"`
|
||||
}
|
||||
|
||||
type ContainerNode struct {
|
||||
Cluster string `json:"cluster"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Label string `json:"label"`
|
||||
Taint string `json:"taint"`
|
||||
Spec string `json:"spec"`
|
||||
CPU int `json:"cpu"`
|
||||
CPUClass string `json:"cpuClass"`
|
||||
Status string `json:"status"`
|
||||
StatusClass string `json:"statusClass"`
|
||||
}
|
||||
|
||||
type ContainerServiceData struct {
|
||||
Summary ContainerServiceSummary `json:"summary"`
|
||||
Workloads []ContainerWorkload `json:"items"`
|
||||
Clusters []ContainerCluster `json:"clusters"`
|
||||
Nodes []ContainerNode `json:"nodes"`
|
||||
}
|
||||
|
||||
type ContainerBusinessLineQuery struct {
|
||||
@@ -81,7 +110,7 @@ func (s *WayneRoleBindingService) ContainerServiceData(ctx context.Context, quer
|
||||
Namespaces: len(query.Namespaces),
|
||||
}
|
||||
workloads := make([]ContainerWorkload, 0)
|
||||
clusterNodes := map[string]wayneNodeSummary{}
|
||||
clusterNodes := map[string]wayneNodeData{}
|
||||
seenWorkloads := map[string]struct{}{}
|
||||
errors := make([]string, 0)
|
||||
|
||||
@@ -142,7 +171,9 @@ func (s *WayneRoleBindingService) ContainerServiceData(ctx context.Context, quer
|
||||
}
|
||||
}
|
||||
|
||||
for _, nodes := range clusterNodes {
|
||||
clusters := make([]ContainerCluster, 0, len(clusterNodes))
|
||||
allNodes := make([]ContainerNode, 0)
|
||||
for clusterName, nodes := range clusterNodes {
|
||||
summary.Nodes += nodes.Total
|
||||
summary.ReadyNodes += nodes.Ready
|
||||
summary.SchedulableNodes += nodes.Schedulable
|
||||
@@ -150,6 +181,10 @@ func (s *WayneRoleBindingService) ContainerServiceData(ctx context.Context, quer
|
||||
summary.CPUTotal += nodes.CPUTotal
|
||||
summary.MemoryUsed += nodes.MemoryUsed
|
||||
summary.MemoryTotal += nodes.MemoryTotal
|
||||
clusters = append(clusters, containerClusterFromWayne(clusterName, nodes))
|
||||
for _, node := range nodes.Nodes {
|
||||
allNodes = append(allNodes, containerNodeFromWayne(clusterName, node, query.BusinessLineName))
|
||||
}
|
||||
}
|
||||
summary.Clusters = len(clusterNodes)
|
||||
summary.Workloads = len(workloads)
|
||||
@@ -157,7 +192,9 @@ func (s *WayneRoleBindingService) ContainerServiceData(ctx context.Context, quer
|
||||
summary.MemoryPercent = ratioPercent(summary.MemoryUsed, summary.MemoryTotal)
|
||||
summary.Errors = errors
|
||||
|
||||
return &ContainerServiceData{Summary: summary, Workloads: workloads}, nil
|
||||
sortContainerClusters(clusters)
|
||||
sortContainerNodes(allNodes)
|
||||
return &ContainerServiceData{Summary: summary, Workloads: workloads, Clusters: clusters, Nodes: allNodes}, nil
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) ListDeploymentHistories(ctx context.Context, namespaces []model.BusinessLineWayneNamespace, limit int) ([]WayneDeploymentHistory, error) {
|
||||
@@ -264,10 +301,10 @@ func (s *WayneRoleBindingService) listWaynePublishHistories(ctx context.Context,
|
||||
return parseWaynePublishHistories(result.Body)
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) getWayneNodes(ctx context.Context, cluster string) (wayneNodeSummary, error) {
|
||||
func (s *WayneRoleBindingService) getWayneNodes(ctx context.Context, cluster string) (wayneNodeData, error) {
|
||||
result, err := s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/kubernetes/nodes/clusters/%s", url.PathEscape(cluster)), nil)
|
||||
if err != nil {
|
||||
return wayneNodeSummary{}, err
|
||||
return wayneNodeData{}, err
|
||||
}
|
||||
return parseWayneNodeSummary(result.Body)
|
||||
}
|
||||
@@ -323,7 +360,7 @@ type wayneDeployment struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type wayneNodeSummary struct {
|
||||
type wayneNodeData struct {
|
||||
Total int
|
||||
Ready int
|
||||
Schedulable int
|
||||
@@ -331,6 +368,23 @@ type wayneNodeSummary struct {
|
||||
CPUTotal float64
|
||||
MemoryUsed float64
|
||||
MemoryTotal float64
|
||||
Nodes []wayneNode
|
||||
}
|
||||
|
||||
type wayneNode struct {
|
||||
Name string `json:"name"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
Spec struct {
|
||||
Unschedulable bool `json:"unschedulable"`
|
||||
Taints []map[string]any `json:"taints"`
|
||||
Ready string `json:"ready"`
|
||||
} `json:"spec"`
|
||||
Status struct {
|
||||
Capacity map[string]string `json:"capacity"`
|
||||
NodeInfo struct {
|
||||
KubeletVersion string `json:"kubeletVersion"`
|
||||
} `json:"nodeInfo"`
|
||||
} `json:"status"`
|
||||
}
|
||||
|
||||
func parseWayneNamespaceDetail(body []byte) (wayneNamespaceDetail, error) {
|
||||
@@ -420,7 +474,7 @@ func sortWayneDeploymentHistories(items []WayneDeploymentHistory) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseWayneNodeSummary(body []byte) (wayneNodeSummary, error) {
|
||||
func parseWayneNodeSummary(body []byte) (wayneNodeData, error) {
|
||||
var wrapped struct {
|
||||
Data struct {
|
||||
NodeSummary struct {
|
||||
@@ -436,12 +490,13 @@ func parseWayneNodeSummary(body []byte) (wayneNodeSummary, error) {
|
||||
Total float64 `json:"total"`
|
||||
Used float64 `json:"used"`
|
||||
} `json:"memorySummary"`
|
||||
Nodes []wayneNode `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||
return wayneNodeSummary{}, err
|
||||
return wayneNodeData{}, err
|
||||
}
|
||||
return wayneNodeSummary{
|
||||
return wayneNodeData{
|
||||
Total: wrapped.Data.NodeSummary.Total,
|
||||
Ready: wrapped.Data.NodeSummary.Ready,
|
||||
Schedulable: wrapped.Data.NodeSummary.Schedulable,
|
||||
@@ -449,9 +504,159 @@ func parseWayneNodeSummary(body []byte) (wayneNodeSummary, error) {
|
||||
CPUTotal: wrapped.Data.CPUSummary.Total,
|
||||
MemoryUsed: wrapped.Data.MemorySummary.Used,
|
||||
MemoryTotal: wrapped.Data.MemorySummary.Total,
|
||||
Nodes: wrapped.Data.Nodes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func containerClusterFromWayne(name string, data wayneNodeData) ContainerCluster {
|
||||
status := "健康"
|
||||
statusClass := "ok"
|
||||
if data.Total == 0 {
|
||||
status = "无节点"
|
||||
statusClass = "idle"
|
||||
} else if data.Ready < data.Total {
|
||||
status = strconv.Itoa(data.Total-data.Ready) + " 节点告警"
|
||||
statusClass = "warn"
|
||||
}
|
||||
cpu := ratioPercent(data.CPUUsed, data.CPUTotal)
|
||||
version := "-"
|
||||
for _, node := range data.Nodes {
|
||||
if strings.TrimSpace(node.Status.NodeInfo.KubeletVersion) != "" {
|
||||
version = strings.TrimSpace(node.Status.NodeInfo.KubeletVersion)
|
||||
break
|
||||
}
|
||||
}
|
||||
return ContainerCluster{
|
||||
Name: name,
|
||||
Zone: "-",
|
||||
Status: status,
|
||||
StatusClass: statusClass,
|
||||
Nodes: data.Total,
|
||||
ReadyNodes: data.Ready,
|
||||
CPU: cpu,
|
||||
CPUClass: warnClass(cpu),
|
||||
Version: version,
|
||||
Calico: "-",
|
||||
}
|
||||
}
|
||||
|
||||
func containerNodeFromWayne(cluster string, node wayneNode, businessLineName string) ContainerNode {
|
||||
cpu := 0
|
||||
if node.Status.Capacity != nil {
|
||||
cpu, _ = strconv.Atoi(strings.TrimSpace(node.Status.Capacity["cpu"]))
|
||||
}
|
||||
memory := strings.TrimSpace(node.Status.Capacity["memory"])
|
||||
spec := "-"
|
||||
if cpu > 0 && memory != "" {
|
||||
spec = fmt.Sprintf("%dC/%sG", cpu, memory)
|
||||
} else if cpu > 0 {
|
||||
spec = fmt.Sprintf("%dC", cpu)
|
||||
} else if memory != "" {
|
||||
spec = memory + "G"
|
||||
}
|
||||
ready := strings.EqualFold(strings.TrimSpace(node.Spec.Ready), "true")
|
||||
status := "Ready"
|
||||
statusClass := "ok"
|
||||
if !ready {
|
||||
status = "NotReady"
|
||||
statusClass = "warn"
|
||||
} else if node.Spec.Unschedulable {
|
||||
status = "Ready · 不可调度"
|
||||
statusClass = "warn"
|
||||
}
|
||||
label := businessLineLabel(node.Labels, businessLineName)
|
||||
return ContainerNode{
|
||||
Cluster: cluster,
|
||||
Name: node.Name,
|
||||
IP: firstNodeIP(node.Labels),
|
||||
Label: label,
|
||||
Taint: formatNodeTaints(node.Spec.Taints),
|
||||
Spec: spec,
|
||||
CPU: 0,
|
||||
CPUClass: "",
|
||||
Status: status,
|
||||
StatusClass: statusClass,
|
||||
}
|
||||
}
|
||||
|
||||
func warnClass(value int) string {
|
||||
if value >= 80 {
|
||||
return "warn"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func businessLineLabel(labels map[string]string, businessLineName string) string {
|
||||
for _, key := range []string{"business-line", "business_line", "xinfra/business-line"} {
|
||||
if value := strings.TrimSpace(labels[key]); value != "" {
|
||||
return key + "=" + value
|
||||
}
|
||||
}
|
||||
if businessLineName != "" {
|
||||
return "business-line=" + businessLineName
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
func firstNodeIP(labels map[string]string) string {
|
||||
for _, key := range []string{"kubernetes.io/hostname", "internal-ip", "node-ip"} {
|
||||
if value := strings.TrimSpace(labels[key]); value != "" && strings.Contains(value, ".") {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
func formatNodeTaints(taints []map[string]any) string {
|
||||
if len(taints) == 0 {
|
||||
return "-"
|
||||
}
|
||||
parts := make([]string, 0, len(taints))
|
||||
for _, taint := range taints {
|
||||
key := strings.TrimSpace(stringFromAny(taint["key"]))
|
||||
value := strings.TrimSpace(stringFromAny(taint["value"]))
|
||||
effect := strings.TrimSpace(stringFromAny(taint["effect"]))
|
||||
text := key
|
||||
if value != "" {
|
||||
text += "=" + value
|
||||
}
|
||||
if effect != "" {
|
||||
text += ":" + effect
|
||||
}
|
||||
if text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func sortContainerClusters(items []ContainerCluster) {
|
||||
for i := 1; i < len(items); i++ {
|
||||
item := items[i]
|
||||
j := i - 1
|
||||
for j >= 0 && items[j].Name > item.Name {
|
||||
items[j+1] = items[j]
|
||||
j--
|
||||
}
|
||||
items[j+1] = item
|
||||
}
|
||||
}
|
||||
|
||||
func sortContainerNodes(items []ContainerNode) {
|
||||
for i := 1; i < len(items); i++ {
|
||||
item := items[i]
|
||||
j := i - 1
|
||||
for j >= 0 && (items[j].Cluster > item.Cluster || (items[j].Cluster == item.Cluster && items[j].Name > item.Name)) {
|
||||
items[j+1] = items[j]
|
||||
j--
|
||||
}
|
||||
items[j+1] = item
|
||||
}
|
||||
}
|
||||
|
||||
func containerWorkloadFromMap(item map[string]any, cluster string, namespace string, businessLineName string) ContainerWorkload {
|
||||
name := firstString(item, "name", "objectMeta.name", "metadata.name")
|
||||
ready := firstInt(item, "pods.current", "readyReplicas", "status.readyReplicas", "availableReplicas", "status.availableReplicas")
|
||||
|
||||
@@ -3,8 +3,11 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -60,6 +63,8 @@ type MySQLDeliveryInput struct {
|
||||
LongQueryTime float64 `json:"long_query_time"`
|
||||
BinlogExpireLogsSeconds int64 `json:"binlog_expire_logs_seconds"`
|
||||
MaxBinlogSize string `json:"max_binlog_size"`
|
||||
MySQLRootPassword string `json:"mysql_root_password"`
|
||||
MySQLAdminPassword string `json:"mysql_admin_password"`
|
||||
}
|
||||
|
||||
type deliveryPayload struct {
|
||||
@@ -110,6 +115,25 @@ type DeliveryTaskSnapshot struct {
|
||||
Events []model.TaskEvent `json:"events"`
|
||||
}
|
||||
|
||||
type MySQLServiceLedgerItem struct {
|
||||
Name string `json:"name"`
|
||||
Datacenter string `json:"datacenter"`
|
||||
BusinessTag string `json:"business_tag"`
|
||||
Instances int `json:"instances"`
|
||||
Healthy int `json:"healthy"`
|
||||
Address string `json:"address"`
|
||||
Status string `json:"status"`
|
||||
StatusClass string `json:"status_class"`
|
||||
Version string `json:"version"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
type DeploymentCredentialView struct {
|
||||
Username string `json:"username"`
|
||||
Host string `json:"host"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// targetMetadata describes the native VM候选节点池以及部署形态,由 AWX inventory hosts 动态组装。
|
||||
type targetMetadata struct {
|
||||
Topology string `json:"topology"`
|
||||
@@ -211,6 +235,52 @@ func bytesToGi(bytes int64) int64 {
|
||||
return bytes / 1073741824
|
||||
}
|
||||
|
||||
func credentialKey(secret string) []byte {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(secret)))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func encryptCredential(secret, plain string) (string, string, error) {
|
||||
block, err := aes.NewCipher(credentialKey(secret))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), base64.StdEncoding.EncodeToString(nonce), nil
|
||||
}
|
||||
|
||||
func decryptCredential(secret, ciphertextValue, nonceValue string) (string, error) {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce, err := base64.StdEncoding.DecodeString(nonceValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(credentialKey(secret))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// firstFreeHost 返回候选池中非失败任务数未达单机实例上限的第一个节点;
|
||||
// limit < 1 时按 1 兜底(退化为旧的一机一实例语义)。顺序遍历天然形成"先摊开、摊满一轮再叠加"。
|
||||
func firstFreeHost(hosts []targetHost, occupied []string, limit int) *targetHost {
|
||||
@@ -406,6 +476,21 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin
|
||||
if err := validateDeliveryInput(input, s.cfg.DeliveryDataDisks); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
credentialInput := map[string]string{
|
||||
"root@localhost": strings.TrimSpace(input.MySQLRootPassword),
|
||||
"xinfra_admin@%": strings.TrimSpace(input.MySQLAdminPassword),
|
||||
}
|
||||
hasCredentialInput := credentialInput["root@localhost"] != "" || credentialInput["xinfra_admin@%"] != ""
|
||||
if hasCredentialInput {
|
||||
if credentialInput["root@localhost"] == "" || credentialInput["xinfra_admin@%"] == "" {
|
||||
return nil, false, fmt.Errorf("mysql_root_password and mysql_admin_password must be provided together")
|
||||
}
|
||||
if len(credentialInput["root@localhost"]) < 16 || len(credentialInput["xinfra_admin@%"]) < 16 {
|
||||
return nil, false, fmt.Errorf("mysql passwords must be at least 16 characters")
|
||||
}
|
||||
}
|
||||
input.MySQLRootPassword = ""
|
||||
input.MySQLAdminPassword = ""
|
||||
|
||||
var existing model.DeliveryTask
|
||||
if err := s.db.WithContext(ctx).Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; err == nil {
|
||||
@@ -457,6 +542,7 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin
|
||||
ID: randomUUID(),
|
||||
BusinessLineID: input.BusinessLineID,
|
||||
RequestedBy: userID,
|
||||
Component: "mysql",
|
||||
TargetType: target.TargetType,
|
||||
TargetID: target.ID,
|
||||
Namespace: input.Namespace,
|
||||
@@ -472,6 +558,11 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if hasCredentialInput {
|
||||
if err := s.storeDeploymentCredentials(ctx, &task, credentialInput, "pending"); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
_ = s.db.WithContext(ctx).Create(&model.TaskEvent{TaskID: task.ID, ToState: model.TaskPending, Message: "delivery task created"}).Error
|
||||
return &task, false, nil
|
||||
}
|
||||
@@ -620,6 +711,137 @@ func (s *DeliveryService) ListTasks(ctx context.Context, userID uint64, isAdmin
|
||||
return tasks, query.Find(&tasks).Error
|
||||
}
|
||||
|
||||
func (s *DeliveryService) ListMySQLServiceLedger(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) ([]MySQLServiceLedgerItem, error) {
|
||||
if businessLineID == 0 {
|
||||
return nil, fmt.Errorf("business_line_id is required")
|
||||
}
|
||||
if !isAdmin {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.BusinessLineUser{}).
|
||||
Where("business_line_id = ? AND user_id = ?", businessLineID, userID).
|
||||
Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("user is not authorized for this business line")
|
||||
}
|
||||
}
|
||||
var businessLine model.BusinessLine
|
||||
if err := s.db.WithContext(ctx).First(&businessLine, "id = ?", businessLineID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var instances []model.DeploymentResult
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("business_line_id = ? AND component = ? AND service_type = ? AND status = ?", businessLineID, "mysql", "database", "active").
|
||||
Order("created_at DESC").
|
||||
Find(&instances).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskIDs := make([]string, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
taskIDs = append(taskIDs, instance.TaskID)
|
||||
}
|
||||
taskStatuses := map[string]string{}
|
||||
if len(taskIDs) > 0 {
|
||||
var tasks []model.DeliveryTask
|
||||
if err := s.db.WithContext(ctx).Select("id", "status").Where("id IN ?", taskIDs).Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
taskStatuses[task.ID] = task.Status
|
||||
}
|
||||
}
|
||||
items := make([]MySQLServiceLedgerItem, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
status := "健康"
|
||||
statusClass := "ok"
|
||||
healthy := 1
|
||||
if taskStatuses[instance.TaskID] == model.TaskRegisterFailed {
|
||||
status = "注册异常"
|
||||
statusClass = "warn"
|
||||
}
|
||||
if instance.Host == "" || instance.Port == 0 {
|
||||
status = "部分异常"
|
||||
statusClass = "warn"
|
||||
healthy = 0
|
||||
}
|
||||
items = append(items, MySQLServiceLedgerItem{
|
||||
Name: instance.InstanceName,
|
||||
Datacenter: instance.NodeName,
|
||||
BusinessTag: businessLine.Name,
|
||||
Instances: 1,
|
||||
Healthy: healthy,
|
||||
Address: fmt.Sprintf("%s:%d", instance.Host, instance.Port),
|
||||
Status: status,
|
||||
StatusClass: statusClass,
|
||||
Version: instance.Version,
|
||||
Namespace: instance.Namespace,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) storeDeploymentCredentials(ctx context.Context, task *model.DeliveryTask, credentials map[string]string, status string) error {
|
||||
for key, password := range credentials {
|
||||
username, host, ok := strings.Cut(key, "@")
|
||||
if !ok || strings.TrimSpace(username) == "" {
|
||||
return fmt.Errorf("invalid credential account %q", key)
|
||||
}
|
||||
ciphertext, nonce, err := encryptCredential(s.cfg.DeliveryCredentialSecret, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item := model.DeploymentCredential{
|
||||
TaskID: task.ID,
|
||||
BusinessLineID: task.BusinessLineID,
|
||||
Component: task.Component,
|
||||
InstanceName: task.InstanceName,
|
||||
Username: strings.TrimSpace(username),
|
||||
AccountHost: strings.TrimSpace(host),
|
||||
Ciphertext: ciphertext,
|
||||
Nonce: nonce,
|
||||
Status: status,
|
||||
Source: "user_input",
|
||||
}
|
||||
if item.Component == "" {
|
||||
item.Component = "mysql"
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) deploymentCredentialVars(ctx context.Context, taskID string) (map[string]string, error) {
|
||||
var items []model.DeploymentCredential
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("task_id = ? AND status IN ?", taskID, []string{"pending", "available"}).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
values := map[string]string{}
|
||||
for _, item := range items {
|
||||
password, err := decryptCredential(s.cfg.DeliveryCredentialSecret, item.Ciphertext, item.Nonce)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch item.Username + "@" + item.AccountHost {
|
||||
case "root@localhost":
|
||||
values["mysql_root_password"] = password
|
||||
case "xinfra_admin@%":
|
||||
values["mysql_admin_password"] = password
|
||||
}
|
||||
}
|
||||
if values["mysql_root_password"] == "" || values["mysql_admin_password"] == "" {
|
||||
return nil, fmt.Errorf("deployment credentials are missing for task %s", taskID)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) GetTask(ctx context.Context, taskID string, userID uint64, isAdmin bool) (*model.DeliveryTask, []model.TaskEvent, error) {
|
||||
query := s.db.WithContext(ctx).Where("id = ?", taskID)
|
||||
if !isAdmin {
|
||||
@@ -636,6 +858,55 @@ func (s *DeliveryService) GetTask(ctx context.Context, taskID string, userID uin
|
||||
return &task, events, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskID string, userID uint64, isAdmin bool) ([]DeploymentCredentialView, error) {
|
||||
task, _, err := s.GetTask(ctx, taskID, userID, isAdmin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task.Status != model.TaskFinished && task.Status != model.TaskRegisterFailed {
|
||||
return nil, fmt.Errorf("task credentials are available only after a successful deployment")
|
||||
}
|
||||
var out []DeploymentCredentialView
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var credentials []model.DeploymentCredential
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("task_id = ? AND status = ?", taskID, "available").
|
||||
Order("id ASC").
|
||||
Find(&credentials).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(credentials) == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
for _, credential := range credentials {
|
||||
password, err := decryptCredential(s.cfg.DeliveryCredentialSecret, credential.Ciphertext, credential.Nonce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, DeploymentCredentialView{
|
||||
Username: credential.Username,
|
||||
Host: credential.AccountHost,
|
||||
Password: password,
|
||||
})
|
||||
}
|
||||
ids := make([]uint64, 0, len(credentials))
|
||||
for _, credential := range credentials {
|
||||
ids = append(ids, credential.ID)
|
||||
}
|
||||
now := time.Now()
|
||||
return tx.Model(&model.DeploymentCredential{}).Where("id IN ?", ids).Updates(map[string]any{
|
||||
"status": "viewed",
|
||||
"viewed_by": userID,
|
||||
"viewed_at": now,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) SubscribeTask(taskID string) (<-chan DeliveryTaskSnapshot, func()) {
|
||||
ch := make(chan DeliveryTaskSnapshot, 8)
|
||||
s.streamMu.Lock()
|
||||
@@ -789,7 +1060,9 @@ func (s *DeliveryService) claimAndReserve(ctx context.Context) (*model.DeliveryT
|
||||
return err
|
||||
}
|
||||
var instancePorts []int
|
||||
if err := tx.Model(&model.MySQLInstance{}).Where("node_name = ? AND status = ?", host.Name, "active").Pluck("port", &instancePorts).Error; err != nil {
|
||||
if err := tx.Model(&model.DeploymentResult{}).
|
||||
Where("component = ? AND service_type = ? AND node_name = ? AND status = ?", "mysql", "database", host.Name, "active").
|
||||
Pluck("port", &instancePorts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
port, portErr := allocatePort(payload.MySQLPort, append(usedPorts, instancePorts...))
|
||||
@@ -887,6 +1160,14 @@ func randomUUID() string {
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func mustJSON(value any) []byte {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return []byte(`{}`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func mysqlReady(ctx context.Context, address string) error {
|
||||
dialer := net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
@@ -952,6 +1233,14 @@ func (s *DeliveryService) CreateExecution(ctx context.Context, taskID, payloadHa
|
||||
return nil, false, err
|
||||
}
|
||||
extraVars := deliveryExtraVars(&task, payload, meta)
|
||||
credentialVars, err := s.deploymentCredentialVars(ctx, task.ID)
|
||||
if err != nil {
|
||||
_ = s.db.WithContext(ctx).Model(&execution).Updates(map[string]any{"status": "failed", "finished_at": time.Now()})
|
||||
return nil, false, err
|
||||
}
|
||||
for key, value := range credentialVars {
|
||||
extraVars[key] = value
|
||||
}
|
||||
extraVars["delivery_callback_url"] = s.deliveryCallbackURL(task.ID)
|
||||
extraVars["delivery_callback_token"] = s.cfg.AWXWebhookToken
|
||||
job, err := s.awx.Launch(ctx, target.AWXTemplateID, AWXLaunchRequest{InventoryID: target.AWXInventoryID, Limit: task.TargetHost, ExtraVars: extraVars})
|
||||
@@ -1302,11 +1591,31 @@ func (s *DeliveryService) completeTask(ctx context.Context, taskID string) error
|
||||
if err := s.transitionTx(tx, &task, model.TaskRegistering, "AWX succeeded and MySQL health check passed", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
instance := model.MySQLInstance{TaskID: task.ID, BusinessLineID: task.BusinessLineID, TargetID: task.TargetID, Namespace: payload.Namespace, Name: payload.InstanceName, NodeName: task.TargetHost, Host: task.TargetHostIP, Port: task.MySQLPort, Version: payload.MySQLVersion, Status: "active"}
|
||||
if err := tx.Create(&instance).Error; err != nil {
|
||||
result := model.DeploymentResult{
|
||||
TaskID: task.ID,
|
||||
BusinessLineID: task.BusinessLineID,
|
||||
Component: task.Component,
|
||||
ServiceType: "database",
|
||||
InstanceName: payload.InstanceName,
|
||||
Namespace: payload.Namespace,
|
||||
TargetID: task.TargetID,
|
||||
NodeName: task.TargetHost,
|
||||
Host: task.TargetHostIP,
|
||||
Port: task.MySQLPort,
|
||||
Version: payload.MySQLVersion,
|
||||
Status: "active",
|
||||
Metadata: string(mustJSON(map[string]any{"data_disk": payload.DataDisk})),
|
||||
}
|
||||
if result.Component == "" {
|
||||
result.Component = "mysql"
|
||||
}
|
||||
if err := tx.Create(&result).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model.ResourceUsage{TaskID: task.ID, InstanceID: instance.ID, BusinessLineID: task.BusinessLineID, TargetID: task.TargetID, CPUMilli: payload.CPUMilli, MemoryMi: payload.MemoryMi, StorageGi: payload.StorageGi, InstanceCount: 1, Status: "active"}).Error; err != nil {
|
||||
if err := tx.Model(&model.DeploymentCredential{}).Where("task_id = ? AND status = ?", task.ID, "pending").Updates(map[string]any{"deployment_result_id": result.ID, "status": "available"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model.ResourceUsage{TaskID: task.ID, InstanceID: result.ID, BusinessLineID: task.BusinessLineID, TargetID: task.TargetID, CPUMilli: payload.CPUMilli, MemoryMi: payload.MemoryMi, StorageGi: payload.StorageGi, InstanceCount: 1, Status: "active"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ResourceReservation{}).Where("task_id = ? AND status = ?", task.ID, "reserved").Update("status", "consumed").Error; err != nil {
|
||||
@@ -1359,11 +1668,11 @@ func (s *DeliveryService) RegisterCloudDM(ctx context.Context, taskID string) er
|
||||
if s.cfg.CloudDMRegisterURL == "" {
|
||||
return nil
|
||||
}
|
||||
var instance model.MySQLInstance
|
||||
if err := s.db.WithContext(ctx).Where("task_id = ?", taskID).First(&instance).Error; err != nil {
|
||||
var instance model.DeploymentResult
|
||||
if err := s.db.WithContext(ctx).Where("task_id = ? AND component = ? AND service_type = ?", taskID, "mysql", "database").First(&instance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{"name": instance.Name, "host": instance.Host, "port": instance.Port, "username": "root", "database_type": "mysql"}
|
||||
body := map[string]any{"name": instance.InstanceName, "host": instance.Host, "port": instance.Port, "username": "root", "database_type": "mysql"}
|
||||
raw, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.CloudDMRegisterURL, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
@@ -1509,7 +1818,7 @@ func (s *DeliveryService) AcknowledgeRollbackRelease(ctx context.Context, taskID
|
||||
if task.Status != model.TaskRollbackFailed {
|
||||
return fmt.Errorf("task %s is in state %q and cannot acknowledge rollback release", taskID, task.Status)
|
||||
}
|
||||
if err := tx.Model(&model.MySQLInstance{}).Where("task_id = ? AND status = ?", taskID, "active").Updates(map[string]any{"status": "rollback_acknowledged", "updated_at": now}).Error; err != nil {
|
||||
if err := tx.Model(&model.DeploymentResult{}).Where("task_id = ? AND component = ? AND service_type = ? AND status = ?", taskID, "mysql", "database", "active").Updates(map[string]any{"status": "rollback_acknowledged", "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ResourceUsage{}).Where("task_id = ? AND status = ?", taskID, "active").Updates(map[string]any{"status": "released", "released_at": now, "updated_at": now}).Error; err != nil {
|
||||
@@ -1627,7 +1936,10 @@ func (s *DeliveryService) completeRollback(ctx context.Context, taskID string) e
|
||||
if task.Status != model.TaskRollingBack {
|
||||
return fmt.Errorf("task %s is in state %q, cannot complete rollback", taskID, task.Status)
|
||||
}
|
||||
if err := tx.Model(&model.MySQLInstance{}).Where("task_id = ?", taskID).Updates(map[string]any{"status": "rolled_back", "updated_at": now}).Error; err != nil {
|
||||
if err := tx.Model(&model.DeploymentResult{}).Where("task_id = ?", taskID).Updates(map[string]any{"status": "rolled_back", "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("task_id = ?", taskID).Delete(&model.DeploymentCredential{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ResourceUsage{}).Where("task_id = ? AND status = ?", taskID, "active").Updates(map[string]any{"status": "released", "released_at": now, "updated_at": now}).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/1024XEngineer/xinfra/server/internal/config"
|
||||
"github.com/1024XEngineer/xinfra/server/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
machineSyncSource = "sina"
|
||||
machineClassName = "zion_server"
|
||||
sinaLoginPath = "/sinai/v1/login"
|
||||
sinaCILotPath = "/sinai/v1/3rd/ci/lot"
|
||||
sinaSyncInterval = 30 * time.Minute
|
||||
sinaRequestTimeout = 10 * time.Second
|
||||
sinaPageSize = 500
|
||||
)
|
||||
|
||||
type MachineService struct {
|
||||
cfg config.Config
|
||||
db *gorm.DB
|
||||
httpClient *http.Client
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
type MachineListQuery struct {
|
||||
Page int
|
||||
Size int
|
||||
Hostname string
|
||||
AssetNumber string
|
||||
Type string
|
||||
Location string
|
||||
IP string
|
||||
Spec string
|
||||
BusinessLine string
|
||||
Source string
|
||||
Status string
|
||||
}
|
||||
|
||||
type MachineResourceItem struct {
|
||||
Hostname string `json:"hostname"`
|
||||
AssetNumber string `json:"assetNumber"`
|
||||
Type string `json:"type"`
|
||||
Location string `json:"location"`
|
||||
IP string `json:"ip"`
|
||||
Spec string `json:"spec"`
|
||||
BusinessLine string `json:"businessLine"`
|
||||
Source string `json:"source"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MachineResourceList struct {
|
||||
Total int64 `json:"total"`
|
||||
Items []MachineResourceItem `json:"items"`
|
||||
}
|
||||
|
||||
type MachineOverview struct {
|
||||
Total int64 `json:"total"`
|
||||
Physical int64 `json:"physical"`
|
||||
Virtual int64 `json:"virtual"`
|
||||
CMDB MachineSourceStat `json:"cmdb"`
|
||||
Aliyun MachineCloudStat `json:"aliyun"`
|
||||
AWSQiniu MachineAWSQiniuStat `json:"awsQiniu"`
|
||||
LastSync MachineLastSyncOverview `json:"lastSync"`
|
||||
}
|
||||
|
||||
type MachineSourceStat struct {
|
||||
Total int64 `json:"total"`
|
||||
Physical int64 `json:"physical"`
|
||||
Virtual int64 `json:"virtual"`
|
||||
}
|
||||
|
||||
type MachineCloudStat struct {
|
||||
Total int64 `json:"total"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type MachineAWSQiniuStat struct {
|
||||
Total int64 `json:"total"`
|
||||
AWS int64 `json:"aws"`
|
||||
Qiniu int64 `json:"qiniu"`
|
||||
}
|
||||
|
||||
type MachineLastSyncOverview struct {
|
||||
Status string `json:"status"`
|
||||
Time string `json:"time"`
|
||||
Created int64 `json:"created"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func NewMachineService(cfg config.Config, db *gorm.DB) *MachineService {
|
||||
return &MachineService{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
httpClient: &http.Client{
|
||||
Timeout: sinaRequestTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MachineService) Run(ctx context.Context) {
|
||||
go func() {
|
||||
_, _ = s.SyncNow(ctx)
|
||||
ticker := time.NewTicker(sinaSyncInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_, _ = s.SyncNow(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *MachineService) SyncNow(ctx context.Context) (*model.MachineSyncState, error) {
|
||||
if err := s.acquireSync(); err != nil {
|
||||
state, _ := s.syncState(ctx)
|
||||
return state, err
|
||||
}
|
||||
defer s.releaseSync()
|
||||
|
||||
started := time.Now()
|
||||
if err := s.startSync(ctx, started); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := s.loginSina(ctx)
|
||||
if err != nil {
|
||||
_ = s.finishSync(ctx, 0, 0, 0, 0, err)
|
||||
state, _ := s.syncState(ctx)
|
||||
return state, err
|
||||
}
|
||||
|
||||
page := 1
|
||||
var total, fetched, created, updated int64
|
||||
for {
|
||||
resp, err := s.fetchSinaPage(ctx, token, page, sinaPageSize)
|
||||
if err != nil {
|
||||
_ = s.finishSync(ctx, total, fetched, created, updated, err)
|
||||
state, _ := s.syncState(ctx)
|
||||
return state, err
|
||||
}
|
||||
if page == 1 {
|
||||
total = resp.Result.Count
|
||||
}
|
||||
if len(resp.Result.Items) == 0 {
|
||||
break
|
||||
}
|
||||
for _, item := range resp.Result.Items {
|
||||
isCreated, err := s.upsertMachine(ctx, item, started)
|
||||
if err != nil {
|
||||
_ = s.finishSync(ctx, total, fetched, created, updated, err)
|
||||
state, _ := s.syncState(ctx)
|
||||
return state, err
|
||||
}
|
||||
fetched++
|
||||
if isCreated {
|
||||
created++
|
||||
} else {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
if total > 0 && fetched >= total {
|
||||
break
|
||||
}
|
||||
if len(resp.Result.Items) < sinaPageSize {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
if err := s.finishSync(ctx, total, fetched, created, updated, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.syncState(ctx)
|
||||
}
|
||||
|
||||
func (s *MachineService) Overview(ctx context.Context) (*MachineOverview, error) {
|
||||
var total, physical int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Where("resource_type = ?", "physical").Count(&physical).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceCounts, err := s.sourceCounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cloudSources := []string{"aliyun", "ali", "alicloud", "aws", "qiniu"}
|
||||
cmdbTotal, err := s.countExcludingSources(ctx, cloudSources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmdbPhysical, err := s.countExcludingSourcesAndType(ctx, cloudSources, "physical")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state, err := s.syncState(ctx)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MachineOverview{
|
||||
Total: total,
|
||||
Physical: physical,
|
||||
Virtual: total - physical,
|
||||
CMDB: MachineSourceStat{
|
||||
Total: cmdbTotal,
|
||||
Physical: cmdbPhysical,
|
||||
Virtual: cmdbTotal - cmdbPhysical,
|
||||
},
|
||||
Aliyun: MachineCloudStat{
|
||||
Total: sourceCounts["aliyun"],
|
||||
Label: "ECS · 增量同步",
|
||||
},
|
||||
AWSQiniu: MachineAWSQiniuStat{
|
||||
Total: sourceCounts["aws"] + sourceCounts["qiniu"],
|
||||
AWS: sourceCounts["aws"],
|
||||
Qiniu: sourceCounts["qiniu"],
|
||||
},
|
||||
LastSync: mapLastSync(state),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MachineService) List(ctx context.Context, query MachineListQuery) (*MachineResourceList, error) {
|
||||
if query.Page <= 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.Size <= 0 {
|
||||
query.Size = 20
|
||||
}
|
||||
if query.Size > 200 {
|
||||
query.Size = 200
|
||||
}
|
||||
|
||||
db := s.db.WithContext(ctx).Model(&model.MachineResource{})
|
||||
db = applyMachineFilters(db, query)
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []model.MachineResource
|
||||
if err := db.Order("updated_at DESC").Offset((query.Page - 1) * query.Size).Limit(query.Size).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]MachineResourceItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, mapMachineResource(row))
|
||||
}
|
||||
return &MachineResourceList{Total: total, Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *MachineService) acquireSync() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.running {
|
||||
return errors.New("machine sync is already running")
|
||||
}
|
||||
s.running = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MachineService) releaseSync() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.running = false
|
||||
}
|
||||
|
||||
func (s *MachineService) startSync(ctx context.Context, started time.Time) error {
|
||||
state := model.MachineSyncState{
|
||||
Source: machineSyncSource,
|
||||
Status: "running",
|
||||
LastStartedAt: &started,
|
||||
LastError: "",
|
||||
}
|
||||
return s.db.WithContext(ctx).Where("source = ?", machineSyncSource).Assign(state).FirstOrCreate(&state).Error
|
||||
}
|
||||
|
||||
func (s *MachineService) finishSync(ctx context.Context, total, fetched, created, updated int64, syncErr error) error {
|
||||
now := time.Now()
|
||||
status := "success"
|
||||
lastErr := ""
|
||||
if syncErr != nil {
|
||||
status = "failed"
|
||||
lastErr = syncErr.Error()
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.MachineSyncState{}).
|
||||
Where("source = ?", machineSyncSource).
|
||||
Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"last_finished_at": &now,
|
||||
"last_error": lastErr,
|
||||
"total": total,
|
||||
"fetched": fetched,
|
||||
"created_count": created,
|
||||
"updated_count": updated,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *MachineService) syncState(ctx context.Context) (*model.MachineSyncState, error) {
|
||||
var state model.MachineSyncState
|
||||
err := s.db.WithContext(ctx).Where("source = ?", machineSyncSource).First(&state).Error
|
||||
return &state, err
|
||||
}
|
||||
|
||||
type sinaPageResp struct {
|
||||
Success bool `json:"success"`
|
||||
Result struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"result"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type sinaLoginResp struct {
|
||||
Success bool `json:"success"`
|
||||
Result map[string]interface{} `json:"result"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Token string `json:"token"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *MachineService) loginSina(ctx context.Context) (string, error) {
|
||||
username := strings.TrimSpace(s.cfg.SINAUsername)
|
||||
password := strings.TrimSpace(s.cfg.SINAPassword)
|
||||
if username == "" || password == "" {
|
||||
return "", errors.New("SINA_USERNAME or SINA_PASSWORD is not configured")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.SINABaseURL+sinaLoginPath, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("content-type", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("sina login failed: status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var parsed sinaLoginResp
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !parsed.Success {
|
||||
return "", fmt.Errorf("sina login failed: %s", parsed.Message)
|
||||
}
|
||||
token := firstNonEmpty(
|
||||
parsed.Token,
|
||||
machineStringValue(parsed.Result["token"]),
|
||||
machineStringValue(parsed.Result["access_token"]),
|
||||
machineStringValue(parsed.Data["token"]),
|
||||
machineStringValue(parsed.Data["access_token"]),
|
||||
)
|
||||
if token == "" {
|
||||
return "", errors.New("sina login response missing token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *MachineService) fetchSinaPage(ctx context.Context, token string, page, size int) (*sinaPageResp, error) {
|
||||
body := map[string]interface{}{
|
||||
"className": machineClassName,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"field": map[string]interface{}{},
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.SINABaseURL+sinaCILotPath, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("content-type", "application/json")
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("sina request failed: status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
var parsed sinaPageResp
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.Success {
|
||||
return nil, fmt.Errorf("sina request failed: %s", parsed.Message)
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (s *MachineService) upsertMachine(ctx context.Context, item map[string]interface{}, syncedAt time.Time) (bool, error) {
|
||||
sinaID := machineInt64Value(item["id"])
|
||||
if sinaID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
raw, _ := json.Marshal(item)
|
||||
next := model.MachineResource{
|
||||
SinaID: sinaID,
|
||||
CIID: machineStringValue(item["ciId"]),
|
||||
Hostname: machineStringValue(item["hostname"]),
|
||||
Name: machineStringValue(item["name"]),
|
||||
AssetNumber: machineStringValue(item["asset_number"]),
|
||||
IDCNumber: machineStringValue(item["idc_number"]),
|
||||
ResourceType: machineStringValue(item["type"]),
|
||||
LocationName: machineStringValue(item["location_name"]),
|
||||
LocationAlias: machineStringValue(item["location_alias"]),
|
||||
IntranetIP: machineStringValue(item["intranet_ip"]),
|
||||
Spec: machineStringValue(item["spec"]),
|
||||
BusinessLine: machineStringValue(item["org_name"]),
|
||||
Source: machineStringValue(item["source"]),
|
||||
Status: machineStringValue(item["status"]),
|
||||
Brand: machineStringValue(item["brand"]),
|
||||
Model: machineStringValue(item["model"]),
|
||||
SerialNumber: machineStringValue(item["serial_number"]),
|
||||
PowerStatus: machineStringValue(item["power_status"]),
|
||||
CPUCores: machineInt64Value(item["cpu_cores"]),
|
||||
CPUName: machineStringValue(item["cpu_name"]),
|
||||
Memory: machineStringValue(item["memory"]),
|
||||
HardCapacity: machineStringValue(item["hard_capacity"]),
|
||||
SSDCapacity: machineStringValue(item["ssd_capacity"]),
|
||||
OOBIP: machineStringValue(item["oob_ip"]),
|
||||
OSFamily: machineStringValue(item["os_family"]),
|
||||
OSVersion: machineStringValue(item["os_version"]),
|
||||
CollectTime: parseSinaTime(machineStringValue(item["collect_time"])),
|
||||
SinaUpdatedAt: parseSinaTime(machineStringValue(item["updated"])),
|
||||
RawJSON: string(raw),
|
||||
LastSyncedAt: syncedAt,
|
||||
}
|
||||
|
||||
var current model.MachineResource
|
||||
err := s.db.WithContext(ctx).Where("sina_id = ?", sinaID).First(¤t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return true, s.db.WithContext(ctx).Create(&next).Error
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
next.ID = current.ID
|
||||
next.CreatedAt = current.CreatedAt
|
||||
return false, s.db.WithContext(ctx).Save(&next).Error
|
||||
}
|
||||
|
||||
func applyMachineFilters(db *gorm.DB, query MachineListQuery) *gorm.DB {
|
||||
switch query.Type {
|
||||
case "physical":
|
||||
db = db.Where("resource_type = ?", "physical")
|
||||
case "vm", "virtual":
|
||||
db = db.Where("resource_type <> ?", "physical")
|
||||
}
|
||||
|
||||
switch query.Source {
|
||||
case "cmdb":
|
||||
db = db.Where("source NOT IN ?", []string{"aliyun", "ali", "alicloud", "aws", "qiniu"})
|
||||
case "aliyun":
|
||||
db = db.Where("source IN ?", []string{"aliyun", "ali", "alicloud"})
|
||||
case "aws":
|
||||
db = db.Where("source = ?", "aws")
|
||||
case "qiniu":
|
||||
db = db.Where("source = ?", "qiniu")
|
||||
}
|
||||
|
||||
if query.Status != "" {
|
||||
db = db.Where("status = ?", query.Status)
|
||||
}
|
||||
if query.Hostname != "" {
|
||||
like := "%" + query.Hostname + "%"
|
||||
db = db.Where("hostname LIKE ? OR name LIKE ?", like, like)
|
||||
}
|
||||
if query.AssetNumber != "" {
|
||||
like := "%" + query.AssetNumber + "%"
|
||||
db = db.Where("asset_number LIKE ? OR idc_number LIKE ?", like, like)
|
||||
}
|
||||
if query.Location != "" {
|
||||
like := "%" + query.Location + "%"
|
||||
db = db.Where("location_name LIKE ? OR location_alias LIKE ?", like, like)
|
||||
}
|
||||
if query.IP != "" {
|
||||
db = db.Where("intranet_ip LIKE ?", "%"+query.IP+"%")
|
||||
}
|
||||
if query.Spec != "" {
|
||||
db = db.Where("spec LIKE ?", "%"+query.Spec+"%")
|
||||
}
|
||||
if query.BusinessLine != "" {
|
||||
db = db.Where("business_line LIKE ?", "%"+query.BusinessLine+"%")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (s *MachineService) sourceCounts(ctx context.Context) (map[string]int64, error) {
|
||||
var rows []struct {
|
||||
Source string
|
||||
Count int64
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Select("source, count(*) as count").Group("source").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
for _, row := range rows {
|
||||
key := classifyMachineSource(row.Source)
|
||||
out[key] += row.Count
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MachineService) countExcludingSources(ctx context.Context, sources []string) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Where("source NOT IN ?", sources).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *MachineService) countExcludingSourcesAndType(ctx context.Context, sources []string, resourceType string) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Where("source NOT IN ? AND resource_type = ?", sources, resourceType).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func mapMachineResource(row model.MachineResource) MachineResourceItem {
|
||||
return MachineResourceItem{
|
||||
Hostname: firstNonEmpty(row.Hostname, row.Name),
|
||||
AssetNumber: firstNonEmpty(row.AssetNumber, row.IDCNumber, "—"),
|
||||
Type: displayMachineType(row.ResourceType),
|
||||
Location: firstNonEmpty(row.LocationName, row.LocationAlias),
|
||||
IP: row.IntranetIP,
|
||||
Spec: row.Spec,
|
||||
BusinessLine: row.BusinessLine,
|
||||
Source: displayMachineSource(row.Source),
|
||||
Status: row.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func mapLastSync(state *model.MachineSyncState) MachineLastSyncOverview {
|
||||
if state == nil || state.ID == 0 || state.LastFinishedAt == nil {
|
||||
return MachineLastSyncOverview{Status: "unknown", Time: "—"}
|
||||
}
|
||||
status := "normal"
|
||||
if state.Status == "failed" {
|
||||
status = "failed"
|
||||
}
|
||||
return MachineLastSyncOverview{
|
||||
Status: status,
|
||||
Time: state.LastFinishedAt.Format("15:04:05"),
|
||||
Created: state.CreatedCount,
|
||||
Error: state.LastError,
|
||||
}
|
||||
}
|
||||
|
||||
func classifyMachineSource(source string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||
case "aliyun", "ali", "alicloud":
|
||||
return "aliyun"
|
||||
case "aws":
|
||||
return "aws"
|
||||
case "qiniu":
|
||||
return "qiniu"
|
||||
default:
|
||||
return "cmdb"
|
||||
}
|
||||
}
|
||||
|
||||
func displayMachineSource(source string) string {
|
||||
switch classifyMachineSource(source) {
|
||||
case "aliyun":
|
||||
return "阿里云同步"
|
||||
case "aws":
|
||||
return "AWS 同步"
|
||||
case "qiniu":
|
||||
return "七牛同步"
|
||||
default:
|
||||
return "SINA CMDB"
|
||||
}
|
||||
}
|
||||
|
||||
func displayMachineType(value string) string {
|
||||
if strings.EqualFold(value, "physical") {
|
||||
return "物理机"
|
||||
}
|
||||
return "虚机"
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func machineStringValue(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case fmt.Stringer:
|
||||
return strings.TrimSpace(v.String())
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
func machineInt64Value(value interface{}) int64 {
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
case json.Number:
|
||||
n, _ := v.Int64()
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseSinaTime(value string) *time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.HasPrefix(value, "0001-") {
|
||||
return nil
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
Reference in New Issue
Block a user