feat(delivery): refine base service integrations

This commit is contained in:
mac
2026-07-29 16:36:27 +08:00
parent 2ca08f9360
commit 35875218c9
22 changed files with 1042 additions and 377 deletions
+1
View File
@@ -18,6 +18,7 @@ func AutoMigrate(db *gorm.DB) error {
&model.BusinessLine{},
&model.BusinessLineUser{},
&model.BusinessLineWayneNamespace{},
&model.BusinessLineSinaOrganization{},
&model.AccessToken{},
&model.WayneToken{},
&model.AuditLog{},
+273 -4
View File
@@ -1,12 +1,19 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/model"
"github.com/1024XEngineer/xinfra/server/internal/service"
@@ -15,8 +22,10 @@ import (
)
type BusinessLineHandler struct {
db *gorm.DB
wayne *service.WayneRoleBindingService
cfg config.Config
db *gorm.DB
wayne *service.WayneRoleBindingService
httpClient *http.Client
}
type BusinessLineWithPermission struct {
@@ -47,8 +56,24 @@ type WayneNamespaceBindingItem struct {
KubeNamespace string `json:"kubeNamespace"`
}
func NewBusinessLineHandler(db *gorm.DB, wayne *service.WayneRoleBindingService) *BusinessLineHandler {
return &BusinessLineHandler{db: db, wayne: wayne}
type SinaOrganizationBindingPayload struct {
Organizations []SinaOrganizationBindingItem `json:"organizations"`
}
type SinaOrganizationBindingItem struct {
ID string `json:"id" binding:"required"`
Name string `json:"name"`
}
func NewBusinessLineHandler(cfg config.Config, db *gorm.DB, wayne *service.WayneRoleBindingService) *BusinessLineHandler {
return &BusinessLineHandler{
cfg: cfg,
db: db,
wayne: wayne,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (h *BusinessLineHandler) ListCurrentUserBusinessLines(c *gin.Context) {
@@ -405,6 +430,250 @@ func (h *BusinessLineHandler) ReplaceWayneNamespaces(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *BusinessLineHandler) ListSinaOrganizations(c *gin.Context) {
businessLineID, ok := parseBusinessLineID(c)
if !ok {
return
}
if !h.canManageBusinessLine(c, businessLineID) {
return
}
token, err := h.loginSina(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
items, err := h.fetchSinaOrganizations(c.Request.Context(), token, c.Query("keyword"))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *BusinessLineHandler) ListSinaOrganizationMappings(c *gin.Context) {
businessLineID, ok := parseBusinessLineID(c)
if !ok {
return
}
if !h.canManageBusinessLine(c, businessLineID) {
return
}
var rows []model.BusinessLineSinaOrganization
if err := h.db.Where("business_line_id = ?", businessLineID).Order("sina_organization_name ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, gin.H{
"id": row.SinaOrganizationID,
"name": row.SinaOrganizationName,
})
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *BusinessLineHandler) ReplaceSinaOrganizationMappings(c *gin.Context) {
businessLineID, ok := parseBusinessLineID(c)
if !ok {
return
}
if !h.canManageBusinessLine(c, businessLineID) {
return
}
var req SinaOrganizationBindingPayload
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("business_line_id = ?", businessLineID).Delete(&model.BusinessLineSinaOrganization{}).Error; err != nil {
return err
}
seen := map[string]struct{}{}
for _, item := range req.Organizations {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
row := model.BusinessLineSinaOrganization{
BusinessLineID: businessLineID,
SinaOrganizationID: id,
SinaOrganizationName: strings.TrimSpace(item.Name),
}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return nil
}); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
type sinaBusinessLoginResp 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"`
}
type sinaOrganizationListResp struct {
Success bool `json:"success"`
Result struct {
Items []map[string]interface{} `json:"items"`
Count int64 `json:"count"`
} `json:"result"`
Message string `json:"message"`
}
func (h *BusinessLineHandler) loginSina(ctx context.Context) (string, error) {
username := strings.TrimSpace(h.cfg.SINAUsername)
password := strings.TrimSpace(h.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, h.cfg.SINABaseURL+"/sinai/v1/login", bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("content-type", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, 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(body))
}
var parsed sinaBusinessLoginResp
if err := json.Unmarshal(body, &parsed); err != nil {
return "", err
}
if !parsed.Success {
return "", fmt.Errorf("sina login failed: %s", parsed.Message)
}
token := firstNonEmptyString(
parsed.Token,
sinaStringValue(parsed.Result["token"]),
sinaStringValue(parsed.Result["access_token"]),
sinaStringValue(parsed.Data["token"]),
sinaStringValue(parsed.Data["access_token"]),
)
if token == "" {
return "", errors.New("sina login response missing token")
}
return token, nil
}
func (h *BusinessLineHandler) fetchSinaOrganizations(ctx context.Context, token string, keyword string) ([]SinaOrganizationBindingItem, error) {
const size = 100
page := 1
items := make([]SinaOrganizationBindingItem, 0)
for {
values := url.Values{}
values.Set("ciClsName", "zion_organization")
values.Set("keyword", keyword)
values.Set("page", strconv.Itoa(page))
values.Set("size", strconv.Itoa(size))
values.Set("isAccurate", "false")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.cfg.SINABaseURL+"/sinai/v1/ci?"+values.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", token)
req.Header.Set("Accept", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("sina organization list failed: status=%d body=%s", resp.StatusCode, string(body))
}
var parsed sinaOrganizationListResp
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
if !parsed.Success {
return nil, fmt.Errorf("sina organization list failed: %s", parsed.Message)
}
for _, row := range parsed.Result.Items {
id := firstNonEmptyString(sinaStringValue(row["id"]), sinaStringValue(row["ciId"]))
name := firstNonEmptyString(
sinaStringValue(row["name"]),
sinaStringValue(row["org_name"]),
sinaStringValue(row["title"]),
id,
)
if id == "" {
continue
}
items = append(items, SinaOrganizationBindingItem{ID: id, Name: name})
}
if len(parsed.Result.Items) < size || int64(len(items)) >= parsed.Result.Count {
break
}
page++
}
return items, nil
}
func sinaStringValue(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 firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func (h *BusinessLineHandler) canManageBusinessLine(c *gin.Context, businessLineID uint64) bool {
claims, ok := CurrentClaims(c)
if !ok {
+2 -2
View File
@@ -171,7 +171,7 @@ func (h *DeliveryHandler) RevealCredentials(c *gin.Context) {
}
items, err := h.service.RevealDeploymentCredentials(c.Request.Context(), c.Param("id"), claims.UserID, claims.IsAdmin)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "credentials not found or already viewed"})
c.JSON(http.StatusNotFound, gin.H{"error": "credentials not found or already claimed"})
return
}
if err != nil {
@@ -357,7 +357,7 @@ func (h *DeliveryHandler) TargetHostMountPaths(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_id"})
return
}
items, err := h.service.ListHostMountPaths(c.Request.Context(), targetID, c.Param("host"))
items, err := h.service.ListHostMountPaths(c.Request.Context(), targetID, c.Param("host"), c.Query("prefix"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+45 -13
View File
@@ -18,7 +18,17 @@ func NewMachineHandler(machines *service.MachineService) *MachineHandler {
}
func (h *MachineHandler) Overview(c *gin.Context) {
overview, err := h.machines.Overview(c.Request.Context())
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
businessLineID, ok := queryUint(c, "business_line_id")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "business_line_id is required"})
return
}
overview, err := h.machines.Overview(c.Request.Context(), claims.UserID, claims.IsAdmin, businessLineID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -27,18 +37,28 @@ func (h *MachineHandler) Overview(c *gin.Context) {
}
func (h *MachineHandler) List(c *gin.Context) {
resources, err := h.machines.List(c.Request.Context(), service.MachineListQuery{
Page: queryInt(c, "page", 1),
Size: queryInt(c, "size", 20),
Hostname: c.Query("hostname"),
AssetNumber: c.Query("assetNumber"),
Type: c.Query("type"),
Location: c.Query("location"),
IP: c.Query("ip"),
Spec: c.Query("spec"),
BusinessLine: c.Query("businessLine"),
Source: c.Query("source"),
Status: c.Query("status"),
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
businessLineID, ok := queryUint(c, "business_line_id")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "business_line_id is required"})
return
}
resources, err := h.machines.List(c.Request.Context(), claims.UserID, claims.IsAdmin, service.MachineListQuery{
BusinessLineID: businessLineID,
Page: queryInt(c, "page", 1),
Size: queryInt(c, "size", 20),
Hostname: c.Query("hostname"),
AssetNumber: c.Query("assetNumber"),
Type: c.Query("type"),
Location: c.Query("location"),
IP: c.Query("ip"),
Spec: c.Query("spec"),
Source: c.Query("source"),
Status: c.Query("status"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -67,3 +87,15 @@ func queryInt(c *gin.Context, key string, fallback int) int {
}
return n
}
func queryUint(c *gin.Context, key string) (uint64, bool) {
value := c.Query(key)
if value == "" {
return 0, false
}
n, err := strconv.ParseUint(value, 10, 64)
if err != nil || n == 0 {
return 0, false
}
return n, true
}
+21 -20
View File
@@ -34,26 +34,27 @@ type ResourceQuota struct {
}
type DeliveryTask struct {
ID string `gorm:"size:36;primaryKey" json:"id"`
BusinessLineID uint64 `gorm:"not null;index" json:"business_line_id"`
RequestedBy uint64 `gorm:"not null;index" json:"requested_by"`
Component string `gorm:"size:32;not null;default:mysql;index" json:"component"`
TargetType string `gorm:"size:32;not null" json:"target_type"`
TargetID uint64 `gorm:"not null;index" json:"target_id"`
Namespace string `gorm:"size:63;not null;index" json:"namespace"`
InstanceName string `gorm:"size:63;not null" json:"instance_name"`
TargetHost string `gorm:"size:128" json:"target_host,omitempty"`
TargetHostIP string `gorm:"size:64" json:"target_host_ip,omitempty"`
MySQLPort int `gorm:"column:mysql_port;not null;default:3307" json:"mysql_port"`
Status string `gorm:"size:32;not null;index" json:"status"`
ImmutablePayload string `gorm:"type:json;not null" json:"immutable_payload"`
PayloadHash string `gorm:"size:64;not null" json:"payload_hash"`
IdempotencyKey string `gorm:"size:128;not null;uniqueIndex" json:"idempotency_key"`
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
ID string `gorm:"size:36;primaryKey" json:"id"`
BusinessLineID uint64 `gorm:"not null;index" json:"business_line_id"`
RequestedBy uint64 `gorm:"not null;index" json:"requested_by"`
Component string `gorm:"size:32;not null;default:mysql;index" json:"component"`
TargetType string `gorm:"size:32;not null" json:"target_type"`
TargetID uint64 `gorm:"not null;index" json:"target_id"`
Namespace string `gorm:"size:63;not null;index" json:"namespace"`
InstanceName string `gorm:"size:63;not null" json:"instance_name"`
TargetHost string `gorm:"size:128" json:"target_host,omitempty"`
TargetHostIP string `gorm:"size:64" json:"target_host_ip,omitempty"`
MySQLPort int `gorm:"column:mysql_port;not null;default:3307" json:"mysql_port"`
Status string `gorm:"size:32;not null;index" json:"status"`
ImmutablePayload string `gorm:"type:json;not null" json:"immutable_payload"`
PayloadHash string `gorm:"size:64;not null" json:"payload_hash"`
IdempotencyKey string `gorm:"size:128;not null;uniqueIndex" json:"idempotency_key"`
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
CredentialAvailable bool `gorm:"-" json:"credential_available"`
}
type ResourceReservation struct {
+9
View File
@@ -58,6 +58,15 @@ type BusinessLineWayneNamespace struct {
UpdatedAt time.Time `json:"updated_at"`
}
type BusinessLineSinaOrganization struct {
ID uint64 `gorm:"primaryKey" json:"id"`
BusinessLineID uint64 `gorm:"not null;uniqueIndex:idx_business_line_sina_orgs_unique,priority:1;index" json:"business_line_id"`
SinaOrganizationID string `gorm:"size:128;not null;uniqueIndex:idx_business_line_sina_orgs_unique,priority:2" json:"sina_organization_id"`
SinaOrganizationName string `gorm:"size:255;not null;default:''" json:"sina_organization_name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AccessToken struct {
ID uint64 `gorm:"primaryKey" json:"id"`
UserID uint64 `gorm:"not null;index" json:"user_id"`
+4 -1
View File
@@ -81,7 +81,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
healthHandler := handler.NewHealthHandler(deps.DB)
authHandler := handler.NewAuthHandler(deps.Config, authService)
userHandler := handler.NewUserHandler(deps.DB)
businessLineHandler := handler.NewBusinessLineHandler(deps.DB, wayneRoleBindingService)
businessLineHandler := handler.NewBusinessLineHandler(deps.Config, deps.DB, wayneRoleBindingService)
wayenHandler := handler.NewWayenHandler(deps.DB, wayenService, auditService)
wayneRoleBindingHandler := handler.NewWayneRoleBindingHandler(wayneRoleBindingService, auditService)
subsystemAuthHandler := handler.NewSubsystemAuthHandler(deps.DB, wayneRoleBindingService, auditService)
@@ -125,6 +125,9 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
protected.POST("/business-lines/authorizations", businessLineHandler.GrantPermission)
protected.GET("/business-lines/:id/wayne-namespaces", businessLineHandler.ListWayneNamespaces)
protected.PUT("/business-lines/:id/wayne-namespaces", businessLineHandler.ReplaceWayneNamespaces)
protected.GET("/business-lines/:id/sina-organizations", businessLineHandler.ListSinaOrganizations)
protected.GET("/business-lines/:id/sina-organization-mappings", businessLineHandler.ListSinaOrganizationMappings)
protected.PUT("/business-lines/:id/sina-organization-mappings", businessLineHandler.ReplaceSinaOrganizationMappings)
protected.GET("/wayen/login", wayenHandler.Login)
protected.GET("/wayen/credential", wayenHandler.GetCredential)
protected.PUT("/wayen/credential", wayenHandler.SaveCredential)
+188 -23
View File
@@ -129,9 +129,13 @@ type MySQLServiceLedgerItem struct {
}
type DeploymentCredentialView struct {
Username string `json:"username"`
Host string `json:"host"`
Password string `json:"password"`
Service string `json:"service"`
InstanceName string `json:"instance_name"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
AccountHost string `json:"account_host"`
Password string `json:"password"`
}
// targetMetadata describes the native VM候选节点池以及部署形态,由 AWX inventory hosts 动态组装。
@@ -397,13 +401,19 @@ func (s *DeliveryService) getTarget(ctx context.Context, templateID uint64) (Del
return s.awxDeliveryTarget(ctx, *template)
}
func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint64, hostName string) ([]DeliveryMountPath, error) {
func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint64, hostName string, prefix string) ([]DeliveryMountPath, error) {
if targetID == 0 {
return nil, fmt.Errorf("target_id is required")
}
if hostName == "" || len(hostName) > 253 || !hostNamePattern.MatchString(hostName) {
return nil, fmt.Errorf("host must be a valid inventory host name")
}
prefix = strings.TrimSpace(prefix)
if prefix != "" {
if err := validateDirectoryLookupPrefix(prefix); err != nil {
return nil, err
}
}
template, err := s.awx.GetJobTemplate(ctx, targetID)
if err != nil {
return nil, fmt.Errorf("deployment target is unavailable: %w", err)
@@ -423,9 +433,16 @@ func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint6
return nil, fmt.Errorf("host %q is not in the deployment target inventory", hostName)
}
if s.cfg.AWXFactsTemplateID != 0 {
if err := s.refreshHostFacts(ctx, hostName); err != nil {
stdout, err := s.refreshHostFacts(ctx, hostName, prefix)
if err != nil {
return nil, err
}
if prefix != "" {
return directoryPathsFromAWXStdout(stdout), nil
}
}
if prefix != "" {
return []DeliveryMountPath{}, nil
}
facts, err := s.awx.GetHostFacts(ctx, matched.ID)
if err != nil {
@@ -438,21 +455,86 @@ func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint6
return items, nil
}
func (s *DeliveryService) refreshHostFacts(ctx context.Context, hostName string) error {
func validateDirectoryLookupPrefix(prefix string) error {
if prefix == "" {
return nil
}
if len(prefix) > 512 {
return fmt.Errorf("path prefix is too long")
}
if !strings.HasPrefix(prefix, "/") {
return fmt.Errorf("path prefix must be an absolute path")
}
if strings.ContainsRune(prefix, 0) {
return fmt.Errorf("path prefix contains invalid characters")
}
return nil
}
func directoryPathsFromAWXStdout(stdout string) []DeliveryMountPath {
const marker = "XINFRA_PATH_COMPLETIONS_JSON="
for _, line := range strings.Split(stdout, "\n") {
line = strings.TrimSpace(line)
idx := strings.Index(line, marker)
if idx < 0 {
continue
}
if items, ok := parseDirectoryCompletionJSON(line[idx+len(marker):]); ok {
sort.Slice(items, func(i, j int) bool {
return items[i].Path < items[j].Path
})
return items
}
}
return []DeliveryMountPath{}
}
func parseDirectoryCompletionJSON(raw string) ([]DeliveryMountPath, bool) {
raw = strings.TrimSpace(raw)
candidates := []string{raw}
if strings.Contains(raw, `\"`) {
candidates = append(candidates, strings.ReplaceAll(raw, `\"`, `"`))
}
for _, candidate := range candidates {
start := strings.Index(candidate, "[")
end := strings.LastIndex(candidate, "]")
if start < 0 || end < start {
continue
}
var items []DeliveryMountPath
if err := json.Unmarshal([]byte(candidate[start:end+1]), &items); err == nil {
return items, true
}
}
return nil, false
}
func (s *DeliveryService) refreshHostFacts(ctx context.Context, hostName string, lookupPath string) (string, error) {
extraVars := map[string]any{
"target_hosts": hostName,
}
if lookupPath != "" {
extraVars["lookup_path"] = lookupPath
}
job, err := s.awx.Launch(ctx, s.cfg.AWXFactsTemplateID, AWXLaunchRequest{
Limit: hostName,
Limit: hostName,
ExtraVars: extraVars,
})
if err != nil {
return fmt.Errorf("launch AWX facts job: %w", err)
return "", fmt.Errorf("launch AWX facts job: %w", err)
}
done, err := s.awx.WaitJob(ctx, strconv.FormatUint(job.ID, 10), time.Duration(s.cfg.AWXFactsTimeoutSeconds)*time.Second)
if err != nil {
return err
return "", err
}
if done.Status != "successful" || done.Failed {
return fmt.Errorf("AWX facts job %d finished with status %s", done.ID, done.Status)
return "", fmt.Errorf("AWX facts job %d finished with status %s", done.ID, done.Status)
}
return nil
stdout, err := s.awx.JobStdout(ctx, strconv.FormatUint(job.ID, 10))
if err != nil {
return "", err
}
return stdout, nil
}
func (s *DeliveryService) awxDeliveryTarget(ctx context.Context, template AWXJobTemplate) (DeliveryTarget, error) {
@@ -503,6 +585,9 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin
if len(credentialInput["root@localhost"]) < 16 || len(credentialInput["xinfra_admin@%"]) < 16 {
return nil, false, fmt.Errorf("mysql passwords must be at least 16 characters")
}
if !mysqlPasswordPattern.MatchString(credentialInput["root@localhost"]) || !mysqlPasswordPattern.MatchString(credentialInput["xinfra_admin@%"]) {
return nil, false, fmt.Errorf("mysql passwords may only contain letters and digits")
}
}
input.MySQLRootPassword = ""
input.MySQLAdminPassword = ""
@@ -657,6 +742,8 @@ var (
// timezone 仅接受偏移量(±HH:MM)、SYSTEM 或命名时区(如 Asia/Shanghai)。
var timezonePattern = regexp.MustCompile(`^([+-](0\d|1[0-4]):[0-5]\d|SYSTEM|[A-Za-z]+(?:/[A-Za-z0-9_+-]+)+)$`)
var mysqlPasswordPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
func validateDeliveryInput(input MySQLDeliveryInput, _ []string) error {
if len(input.Namespace) > 63 || !dnsLabelPattern.MatchString(input.Namespace) {
return fmt.Errorf("namespace must be a valid Kubernetes DNS label")
@@ -773,7 +860,13 @@ func (s *DeliveryService) ListTasks(ctx context.Context, userID uint64, isAdmin
query = query.Where("business_line_id IN (?)", s.db.Model(&model.BusinessLineUser{}).Select("business_line_id").Where("user_id = ?", userID))
}
var tasks []model.DeliveryTask
return tasks, query.Find(&tasks).Error
if err := query.Find(&tasks).Error; err != nil {
return nil, err
}
if err := s.fillCredentialAvailability(ctx, tasks); err != nil {
return nil, err
}
return tasks, nil
}
func (s *DeliveryService) ListMySQLServiceLedger(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) ([]MySQLServiceLedgerItem, error) {
@@ -916,6 +1009,11 @@ func (s *DeliveryService) GetTask(ctx context.Context, taskID string, userID uin
if err := query.First(&task).Error; err != nil {
return nil, nil, err
}
taskItems := []model.DeliveryTask{task}
if err := s.fillCredentialAvailability(ctx, taskItems); err != nil {
return nil, nil, err
}
task = taskItems[0]
var events []model.TaskEvent
if err := s.db.WithContext(ctx).Where("task_id = ?", taskID).Order("id ASC").Find(&events).Error; err != nil {
return nil, nil, err
@@ -923,6 +1021,41 @@ func (s *DeliveryService) GetTask(ctx context.Context, taskID string, userID uin
return &task, events, nil
}
func (s *DeliveryService) fillCredentialAvailability(ctx context.Context, tasks []model.DeliveryTask) error {
if len(tasks) == 0 {
return nil
}
taskIDs := make([]string, 0, len(tasks))
for i := range tasks {
if tasks[i].Status == model.TaskFinished || tasks[i].Status == model.TaskRegisterFailed {
taskIDs = append(taskIDs, tasks[i].ID)
}
}
if len(taskIDs) == 0 {
return nil
}
var rows []struct {
TaskID string
Count int64
}
if err := s.db.WithContext(ctx).
Model(&model.DeploymentCredential{}).
Select("task_id, count(*) as count").
Where("task_id IN ? AND status = ?", taskIDs, "available").
Group("task_id").
Scan(&rows).Error; err != nil {
return err
}
available := make(map[string]bool, len(rows))
for _, row := range rows {
available[row.TaskID] = row.Count > 0
}
for i := range tasks {
tasks[i].CredentialAvailable = available[tasks[i].ID]
}
return 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 {
@@ -933,6 +1066,10 @@ func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskI
}
var out []DeploymentCredentialView
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var result model.DeploymentResult
if err := tx.Where("task_id = ?", taskID).First(&result).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var credentials []model.DeploymentCredential
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("task_id = ? AND status = ?", taskID, "available").
@@ -949,22 +1086,20 @@ func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskI
return err
}
out = append(out, DeploymentCredentialView{
Username: credential.Username,
Host: credential.AccountHost,
Password: password,
Service: firstNonEmptyDeliveryValue(result.Component, credential.Component, task.Component, "mysql"),
InstanceName: firstNonEmptyDeliveryValue(result.InstanceName, credential.InstanceName, task.InstanceName),
Host: firstNonEmptyDeliveryValue(result.Host, task.TargetHostIP),
Port: firstNonZero(result.Port, task.MySQLPort),
Username: credential.Username,
AccountHost: 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
return tx.Unscoped().Where("id IN ?", ids).Delete(&model.DeploymentCredential{}).Error
})
if err != nil {
return nil, err
@@ -972,6 +1107,24 @@ func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskI
return out, nil
}
func firstNonEmptyDeliveryValue(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func firstNonZero(values ...int) int {
for _, value := range values {
if value != 0 {
return value
}
}
return 0
}
func (s *DeliveryService) SubscribeTask(taskID string) (<-chan DeliveryTaskSnapshot, func()) {
ch := make(chan DeliveryTaskSnapshot, 8)
s.streamMu.Lock()
@@ -1170,7 +1323,7 @@ func (s *DeliveryService) claimAndReserve(ctx context.Context) (*model.DeliveryT
return s.failInTransaction(tx, &task, model.TaskValidationFailed, "deployment target has no candidate hosts")
}
var occupied []string
occupiedExclude := []string{model.TaskExecutionFailed, model.TaskValidationFailed, model.TaskCanceled}
occupiedExclude := allocationReleasedTaskStatuses()
if err := tx.Model(&model.DeliveryTask{}).Where("target_id = ? AND target_host <> ? AND status NOT IN ?", task.TargetID, "", occupiedExclude).Pluck("target_host", &occupied).Error; err != nil {
return err
}
@@ -1641,6 +1794,18 @@ func terminalTaskStatuses() []string {
}
}
func allocationReleasedTaskStatuses() []string {
return []string{
model.TaskFinished,
model.TaskExecutionFailed,
model.TaskValidationFailed,
model.TaskRegisterFailed,
model.TaskCanceled,
model.TaskRolledBack,
model.TaskRollbackAck,
}
}
func awxNotificationMessage(input AWXJobNotificationInput) string {
status := strings.TrimSpace(input.Status)
name := strings.TrimSpace(input.Name)
+29
View File
@@ -143,6 +143,35 @@ func TestRollbackExtraVarsTargetsOnlyTheAllocatedInstance(t *testing.T) {
}
}
func TestDirectoryPathsFromAWXStdout(t *testing.T) {
stdout := `
TASK [Show directory completions] **********************************************
ok: [db-01] => {
"stdout": "XINFRA_PATH_COMPLETIONS_JSON=[{\"path\":\"/a/bc\",\"available_gi\":12},{\"path\":\"/a/bb\",\"available_gi\":8}]"
}
`
items := directoryPathsFromAWXStdout(stdout)
if len(items) != 2 {
t.Fatalf("expected two completion items, got %#v", items)
}
if items[0].Path != "/a/bb" || items[1].Path != "/a/bc" {
t.Fatalf("items should be parsed and sorted by path: %#v", items)
}
}
func TestValidateDirectoryLookupPrefix(t *testing.T) {
for _, path := range []string{"/", "/a", "/a/b", "/lib/data"} {
if err := validateDirectoryLookupPrefix(path); err != nil {
t.Fatalf("valid path prefix %q rejected: %v", path, err)
}
}
for _, path := range []string{"a", "relative/path"} {
if err := validateDirectoryLookupPrefix(path); err == nil {
t.Fatalf("invalid path prefix %q was accepted", path)
}
}
}
func TestRegisterFailedIsProtectedFromRollback(t *testing.T) {
if !rollbackProtectedStatus(model.TaskRegisterFailed) {
t.Fatal("register_failed must preserve the healthy instance and resource usage")
+64 -29
View File
@@ -37,17 +37,17 @@ type MachineService struct {
}
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
BusinessLineID uint64
Page int
Size int
Hostname string
AssetNumber string
Type string
Location string
IP string
Spec string
Source string
Status string
}
type MachineResourceItem struct {
@@ -189,25 +189,29 @@ func (s *MachineService) SyncNow(ctx context.Context) (*model.MachineSyncState,
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 {
func (s *MachineService) Overview(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) (*MachineOverview, error) {
baseDB, err := s.scopedMachineDB(ctx, userID, isAdmin, businessLineID)
if err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Where("resource_type = ?", "physical").Count(&physical).Error; err != nil {
var total, physical int64
if err := baseDB.Count(&total).Error; err != nil {
return nil, err
}
if err := baseDB.Session(&gorm.Session{}).Where("resource_type = ?", "physical").Count(&physical).Error; err != nil {
return nil, err
}
sourceCounts, err := s.sourceCounts(ctx)
sourceCounts, err := s.sourceCounts(baseDB.Session(&gorm.Session{}))
if err != nil {
return nil, err
}
cloudSources := []string{"aliyun", "ali", "alicloud", "aws", "qiniu"}
cmdbTotal, err := s.countExcludingSources(ctx, cloudSources)
cmdbTotal, err := s.countExcludingSources(baseDB.Session(&gorm.Session{}), cloudSources)
if err != nil {
return nil, err
}
cmdbPhysical, err := s.countExcludingSourcesAndType(ctx, cloudSources, "physical")
cmdbPhysical, err := s.countExcludingSourcesAndType(baseDB.Session(&gorm.Session{}), cloudSources, "physical")
if err != nil {
return nil, err
}
@@ -239,7 +243,7 @@ func (s *MachineService) Overview(ctx context.Context) (*MachineOverview, error)
}, nil
}
func (s *MachineService) List(ctx context.Context, query MachineListQuery) (*MachineResourceList, error) {
func (s *MachineService) List(ctx context.Context, userID uint64, isAdmin bool, query MachineListQuery) (*MachineResourceList, error) {
if query.Page <= 0 {
query.Page = 1
}
@@ -250,7 +254,10 @@ func (s *MachineService) List(ctx context.Context, query MachineListQuery) (*Mac
query.Size = 200
}
db := s.db.WithContext(ctx).Model(&model.MachineResource{})
db, err := s.scopedMachineDB(ctx, userID, isAdmin, query.BusinessLineID)
if err != nil {
return nil, err
}
db = applyMachineFilters(db, query)
var total int64
@@ -270,6 +277,37 @@ func (s *MachineService) List(ctx context.Context, query MachineListQuery) (*Mac
return &MachineResourceList{Total: total, Items: items}, nil
}
func (s *MachineService) scopedMachineDB(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) (*gorm.DB, error) {
if businessLineID == 0 {
return nil, errors.New("business_line_id is required")
}
if !isAdmin {
var binding model.BusinessLineUser
err := s.db.WithContext(ctx).Where("business_line_id = ? AND user_id = ?", businessLineID, userID).First(&binding).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("current user does not have business line permission")
}
if err != nil {
return nil, err
}
}
var mappings []model.BusinessLineSinaOrganization
if err := s.db.WithContext(ctx).Where("business_line_id = ?", businessLineID).Order("sina_organization_name ASC").Find(&mappings).Error; err != nil {
return nil, err
}
names := make([]string, 0, len(mappings))
for _, mapping := range mappings {
if name := strings.TrimSpace(mapping.SinaOrganizationName); name != "" {
names = append(names, name)
}
}
db := s.db.WithContext(ctx).Model(&model.MachineResource{})
if len(names) == 0 {
return db.Where("1 = 0"), nil
}
return db.Where("business_line IN ?", names), nil
}
func (s *MachineService) acquireSync() error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -527,18 +565,15 @@ func applyMachineFilters(db *gorm.DB, query MachineListQuery) *gorm.DB {
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) {
func (s *MachineService) sourceCounts(db *gorm.DB) (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 {
if err := db.Select("source, count(*) as count").Group("source").Scan(&rows).Error; err != nil {
return nil, err
}
out := map[string]int64{}
@@ -549,15 +584,15 @@ func (s *MachineService) sourceCounts(ctx context.Context) (map[string]int64, er
return out, nil
}
func (s *MachineService) countExcludingSources(ctx context.Context, sources []string) (int64, error) {
func (s *MachineService) countExcludingSources(db *gorm.DB, sources []string) (int64, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Where("source NOT IN ?", sources).Count(&count).Error
err := db.Where("source NOT IN ?", sources).Count(&count).Error
return count, err
}
func (s *MachineService) countExcludingSourcesAndType(ctx context.Context, sources []string, resourceType string) (int64, error) {
func (s *MachineService) countExcludingSourcesAndType(db *gorm.DB, 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
err := db.Where("source NOT IN ? AND resource_type = ?", sources, resourceType).Count(&count).Error
return count, err
}