feat(delivery): refine base service integrations

This commit is contained in:
mac
2026-07-29 16:47:41 +08:00
parent 2ca08f9360
commit 35875218c9
22 changed files with 1042 additions and 377 deletions
+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
}