feat: register MySQL instances in CloudDM
This commit is contained in:
@@ -37,6 +37,7 @@ type MySQLDeliveryInput struct {
|
|||||||
TargetID uint64 `json:"target_id" binding:"required"`
|
TargetID uint64 `json:"target_id" binding:"required"`
|
||||||
Namespace string `json:"namespace" binding:"required"`
|
Namespace string `json:"namespace" binding:"required"`
|
||||||
InstanceName string `json:"instance_name" binding:"required"`
|
InstanceName string `json:"instance_name" binding:"required"`
|
||||||
|
InstanceDesc string `json:"instance_desc,omitempty"`
|
||||||
MySQLVersion string `json:"mysql_version"`
|
MySQLVersion string `json:"mysql_version"`
|
||||||
Topology string `json:"topology"`
|
Topology string `json:"topology"`
|
||||||
MySQLPort int `json:"mysql_port"`
|
MySQLPort int `json:"mysql_port"`
|
||||||
@@ -815,6 +816,7 @@ func validateDeliveryInput(input MySQLDeliveryInput, _ []string) error {
|
|||||||
func normalizeMySQLDeliveryInput(input *MySQLDeliveryInput) {
|
func normalizeMySQLDeliveryInput(input *MySQLDeliveryInput) {
|
||||||
input.Namespace = normalizeDNSLabel(input.Namespace)
|
input.Namespace = normalizeDNSLabel(input.Namespace)
|
||||||
input.InstanceName = normalizeDNSLabel(input.InstanceName)
|
input.InstanceName = normalizeDNSLabel(input.InstanceName)
|
||||||
|
input.InstanceDesc = strings.TrimSpace(input.InstanceDesc)
|
||||||
input.TargetHost = strings.TrimSpace(input.TargetHost)
|
input.TargetHost = strings.TrimSpace(input.TargetHost)
|
||||||
input.DataDisk = strings.TrimSpace(input.DataDisk)
|
input.DataDisk = strings.TrimSpace(input.DataDisk)
|
||||||
if input.MySQLVersion == "" {
|
if input.MySQLVersion == "" {
|
||||||
@@ -1971,24 +1973,109 @@ func (s *DeliveryService) RetryCloudDMRegistration(ctx context.Context, taskID s
|
|||||||
return s.transition(ctx, &task, model.TaskFinished, "CloudDM registration completed", "")
|
return s.transition(ctx, &task, model.TaskFinished, "CloudDM registration completed", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cloudDMRegisterRequest struct {
|
||||||
|
SourceSystem string `json:"sourceSystem"`
|
||||||
|
ResourceType string `json:"resourceType"`
|
||||||
|
ExternalResourceID string `json:"externalResourceId"`
|
||||||
|
DataSource cloudDMDataSource `json:"dataSource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloudDMDataSource struct {
|
||||||
|
InstanceName string `json:"instanceName"`
|
||||||
|
InstanceDesc string `json:"instanceDesc"`
|
||||||
|
DSType string `json:"dsType"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
SecurityType string `json:"securityType"`
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DefaultSchema any `json:"defaultSchema"`
|
||||||
|
ClientTimeZone string `json:"clientTimeZone"`
|
||||||
|
ConnectTimeoutMs int `json:"connectTimeoutMs"`
|
||||||
|
SocketTimeoutSecs int `json:"socketTimeoutSeconds"`
|
||||||
|
ConnectionCharset string `json:"connectionCharset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudDMExternalResourceID(instanceID uint64) string {
|
||||||
|
return "mysql-instance:" + strconv.FormatUint(instanceID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudDMClientTimeZone(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "Asia/Shanghai"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCloudDMRegisterRequest(instance model.DeploymentResult, payload deliveryPayload, password string) cloudDMRegisterRequest {
|
||||||
|
description := strings.TrimSpace(payload.InstanceDesc)
|
||||||
|
if description == "" {
|
||||||
|
description = instance.InstanceName
|
||||||
|
}
|
||||||
|
return cloudDMRegisterRequest{
|
||||||
|
SourceSystem: "xinfra",
|
||||||
|
ResourceType: "MYSQL_INSTANCE",
|
||||||
|
ExternalResourceID: cloudDMExternalResourceID(instance.ID),
|
||||||
|
DataSource: cloudDMDataSource{
|
||||||
|
InstanceName: instance.InstanceName,
|
||||||
|
InstanceDesc: description,
|
||||||
|
DSType: "MySQL",
|
||||||
|
Host: net.JoinHostPort(instance.Host, strconv.Itoa(instance.Port)),
|
||||||
|
SecurityType: "USER_PASSWD",
|
||||||
|
UserName: "root",
|
||||||
|
Password: password,
|
||||||
|
DefaultSchema: nil,
|
||||||
|
ClientTimeZone: cloudDMClientTimeZone(payload.Timezone),
|
||||||
|
ConnectTimeoutMs: 5000,
|
||||||
|
SocketTimeoutSecs: 10,
|
||||||
|
ConnectionCharset: "utf8",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DeliveryService) RegisterCloudDM(ctx context.Context, taskID string) error {
|
func (s *DeliveryService) RegisterCloudDM(ctx context.Context, taskID string) error {
|
||||||
if s.cfg.CloudDMRegisterURL == "" {
|
if strings.TrimSpace(s.cfg.CloudDMRegisterURL) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(s.cfg.CloudDMAPIToken) == "" {
|
||||||
|
return fmt.Errorf("CloudDM registration is enabled but CLOUDDM_API_TOKEN is not configured")
|
||||||
|
}
|
||||||
|
var task model.DeliveryTask
|
||||||
|
if err := s.db.WithContext(ctx).First(&task, "id = ?", taskID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var payload deliveryPayload
|
||||||
|
if err := json.Unmarshal([]byte(task.ImmutablePayload), &payload); err != nil {
|
||||||
|
return fmt.Errorf("decode delivery payload: %w", err)
|
||||||
|
}
|
||||||
var instance model.DeploymentResult
|
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 {
|
if err := s.db.WithContext(ctx).Where("task_id = ? AND component = ? AND service_type = ?", taskID, "mysql", "database").First(&instance).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
body := map[string]any{"name": instance.InstanceName, "host": instance.Host, "port": instance.Port, "username": "root", "database_type": "mysql"}
|
var credential model.DeploymentCredential
|
||||||
raw, _ := json.Marshal(body)
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("task_id = ? AND username = ? AND account_host = ? AND status IN ?", taskID, "root", "localhost", []string{"pending", "available"}).
|
||||||
|
First(&credential).Error; err != nil {
|
||||||
|
return fmt.Errorf("CloudDM registration requires the MySQL root credential: %w", err)
|
||||||
|
}
|
||||||
|
password, err := decryptCredential(s.cfg.DeliveryCredentialSecret, credential.Ciphertext, credential.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decrypt MySQL root credential for CloudDM registration: %w", err)
|
||||||
|
}
|
||||||
|
body := buildCloudDMRegisterRequest(instance, payload, password)
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode CloudDM registration request: %w", err)
|
||||||
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.CloudDMRegisterURL, bytes.NewReader(raw))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.CloudDMRegisterURL, bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// X-Request-ID is a trace identifier, so generate a fresh UUID for every
|
||||||
|
// HTTP call. externalResourceId above is the stable business idempotency key.
|
||||||
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(s.cfg.CloudDMAPIToken))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
if s.cfg.CloudDMAPIToken != "" {
|
req.Header.Set("X-Request-ID", randomUUID())
|
||||||
req.Header.Set("Authorization", "Bearer "+s.cfg.CloudDMAPIToken)
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -131,6 +132,38 @@ func TestAllocatePort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildCloudDMRegisterRequest(t *testing.T) {
|
||||||
|
req := buildCloudDMRegisterRequest(
|
||||||
|
model.DeploymentResult{ID: 42, InstanceName: "mysql-payment-prod", Host: "10.0.0.10", Port: 3306},
|
||||||
|
deliveryPayload{MySQLDeliveryInput: MySQLDeliveryInput{InstanceDesc: "支付生产 MySQL", Timezone: "Asia/Shanghai"}},
|
||||||
|
"secret",
|
||||||
|
)
|
||||||
|
if req.SourceSystem != "xinfra" || req.ResourceType != "MYSQL_INSTANCE" {
|
||||||
|
t.Fatalf("unexpected request metadata: %#v", req)
|
||||||
|
}
|
||||||
|
if req.ExternalResourceID != "mysql-instance:42" {
|
||||||
|
t.Fatalf("unexpected externalResourceId: %q", req.ExternalResourceID)
|
||||||
|
}
|
||||||
|
if req.DataSource.Host != "10.0.0.10:3306" || req.DataSource.Password != "secret" {
|
||||||
|
t.Fatalf("unexpected data source fields: %#v", req.DataSource)
|
||||||
|
}
|
||||||
|
if req.DataSource.ClientTimeZone != "Asia/Shanghai" || req.DataSource.ConnectionCharset != "utf8" {
|
||||||
|
t.Fatalf("unexpected time zone or charset: %#v", req.DataSource)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal CloudDM request: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal CloudDM request: %v", err)
|
||||||
|
}
|
||||||
|
dataSource := decoded["dataSource"].(map[string]any)
|
||||||
|
if value, ok := dataSource["defaultSchema"]; !ok || value != nil {
|
||||||
|
t.Fatalf("defaultSchema must be present as null, got %#v", dataSource["defaultSchema"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRollbackExtraVarsTargetsOnlyTheAllocatedInstance(t *testing.T) {
|
func TestRollbackExtraVarsTargetsOnlyTheAllocatedInstance(t *testing.T) {
|
||||||
task := &model.DeliveryTask{ID: "task-1", TargetHost: "db-01"}
|
task := &model.DeliveryTask{ID: "task-1", TargetHost: "db-01"}
|
||||||
payload := deliveryPayload{MySQLDeliveryInput: MySQLDeliveryInput{InstanceName: "mysql-a", DataDisk: "/disk1"}}
|
payload := deliveryPayload{MySQLDeliveryInput: MySQLDeliveryInput{InstanceName: "mysql-a", DataDisk: "/disk1"}}
|
||||||
|
|||||||
Reference in New Issue
Block a user