feat(delivery): sync mysql service ledger and credentials

This commit is contained in:
mac
2026-07-30 16:09:13 +08:00
parent f41abfd609
commit 1aa984c7e5
15 changed files with 1322 additions and 262 deletions
+7
View File
@@ -10,6 +10,10 @@ OIDC_CLOUDDM_CLIENT_ID=clouddm
OIDC_CLOUDDM_CLIENT_SECRET=change-this-clouddm-client-secret
OIDC_CLOUDDM_REDIRECT_URI=
CLOUDDM_TARGET_URL=http://authserver-nginx/internal/clouddm
CLOUDDM_PUBLIC_URL=
CLOUDDM_LOGIN_URL=
CLOUDDM_ADMIN_USERNAME=admin
CLOUDDM_ADMIN_PASSWORD=change-this-clouddm-admin-password
WAYEN_LOGIN_URL=
WAYEN_TARGET_URL=
WAYEN_USERNAME_KEY=username
@@ -56,6 +60,9 @@ AWX_PASSWORD=
AWX_WEBHOOK_TOKEN=
AWX_FACTS_TEMPLATE_ID=
AWX_FACTS_TIMEOUT_SECONDS=45
DELIVERY_MYSQL_TEMPLATE_NAME=XINFRA MySQL Native Prototype
DELIVERY_MYSQL_INSPECT_TEMPLATE_NAME=XINFRA MySQL Inspect
DELIVERY_MYSQL_INSPECT_TIMEOUT_SECONDS=90
# AWX Job Template ID for ansible/mysql-rollback.yml; required for automatic cleanup
DELIVERY_ROLLBACK_TEMPLATE_ID=0
DELIVERY_SERVICE_TOKEN=
+1
View File
@@ -48,6 +48,7 @@ require (
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
+64
View File
@@ -1,3 +1,4 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -11,14 +12,20 @@ github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQ
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
@@ -53,6 +60,21 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -90,6 +112,7 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
@@ -114,6 +137,8 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
@@ -126,24 +151,42 @@ go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -164,11 +207,30 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -187,5 +249,7 @@ gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+146 -132
View File
@@ -14,72 +14,79 @@ type OAuthClient struct {
}
type Config struct {
AppEnv string
HTTPAddr string
PublicBaseURL string
MySQLDSN string
AutoMigrate bool
SSOEnabled bool
JWTSecret string
JWTIssuer string
JWTTTLMinutes int
SAMLEntityID string
SAMLACSURL string
SAMLSPCert string
SAMLSPKey string
SAMLIDPMetaURL string
SAMLLogoutURL string
WayenLoginURL string
WayenTargetURL string
WayenUsernameKey string
WayenPasswordKey string
WayenLoginFormat string
WayenLoginValue string
WayenOAuthRef string
WayenOAuthLoginURL string
WayneAPIBaseURL string
WayneAdminUsername string
WayneAdminPassword string
WayneTokenTTLMinutes int
WayneInternalAPIBaseURL string
WayneServiceName string
WayneServiceAPISecretKey string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
OAuthCodeTTLSeconds int
OIDCIssuer string
OIDCAuthorizeURL string
OIDCTokenURL string
OIDCUserInfoURL string
OIDCJWKSURL string
CloudDMClientID string
CloudDMClientSecret string
CloudDMRedirectURI string
CloudDMTargetURL string
CloudDMRegisterURL string
CloudDMAPIToken string
AWXBaseURL string
AWXToken string
AWXUsername string
AWXPassword string
AWXWebhookToken string
AWXFactsTemplateID uint64
AWXFactsTimeoutSeconds int
RollbackTemplateID uint64
DeliveryServiceToken string
DeliverySchedulerEnabled bool
DeliveryDispatchSeconds int
DeliveryCallbackBaseURL string
DeliveryCredentialSecret string
ReservationTTLMinutes int
DeliveryGlobalLimit int
DeliveryTargetLimit int
DeliveryHostInstanceLimit int
DeliveryDataDisks []string
SINABaseURL string
SINAUsername string
SINAPassword string
AppEnv string
HTTPAddr string
PublicBaseURL string
MySQLDSN string
AutoMigrate bool
SSOEnabled bool
JWTSecret string
JWTIssuer string
JWTTTLMinutes int
SAMLEntityID string
SAMLACSURL string
SAMLSPCert string
SAMLSPKey string
SAMLIDPMetaURL string
SAMLLogoutURL string
WayenLoginURL string
WayenTargetURL string
WayenUsernameKey string
WayenPasswordKey string
WayenLoginFormat string
WayenLoginValue string
WayenOAuthRef string
WayenOAuthLoginURL string
WayneAPIBaseURL string
WayneAdminUsername string
WayneAdminPassword string
WayneTokenTTLMinutes int
WayneInternalAPIBaseURL string
WayneServiceName string
WayneServiceAPISecretKey string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
OAuthCodeTTLSeconds int
OIDCIssuer string
OIDCAuthorizeURL string
OIDCTokenURL string
OIDCUserInfoURL string
OIDCJWKSURL string
CloudDMClientID string
CloudDMClientSecret string
CloudDMRedirectURI string
CloudDMTargetURL string
CloudDMPublicURL string
CloudDMLoginURL string
CloudDMAdminUsername string
CloudDMAdminPassword string
CloudDMRegisterURL string
CloudDMAPIToken string
AWXBaseURL string
AWXToken string
AWXUsername string
AWXPassword string
AWXWebhookToken string
AWXFactsTemplateID uint64
AWXFactsTimeoutSeconds int
MySQLDeliveryTemplateName string
MySQLInspectTemplateName string
MySQLInspectTimeoutSeconds int
RollbackTemplateID uint64
DeliveryServiceToken string
DeliverySchedulerEnabled bool
DeliveryDispatchSeconds int
DeliveryCallbackBaseURL string
DeliveryCredentialSecret string
ReservationTTLMinutes int
DeliveryGlobalLimit int
DeliveryTargetLimit int
DeliveryHostInstanceLimit int
DeliveryDataDisks []string
SINABaseURL string
SINAUsername string
SINAPassword string
}
func Load() Config {
@@ -92,72 +99,79 @@ func Load() Config {
oidcIssuer = strings.TrimRight(oidcIssuer, "/")
return Config{
AppEnv: env("APP_ENV", "dev"),
HTTPAddr: httpAddr,
PublicBaseURL: publicBaseURL,
MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"),
AutoMigrate: envBool("AUTO_MIGRATE", true),
SSOEnabled: envBool("SSO_ENABLED", true),
JWTSecret: env("JWT_SECRET", "change-this-secret"),
JWTIssuer: env("JWT_ISSUER", "authserver"),
JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120),
SAMLEntityID: samlEntityID,
SAMLACSURL: samlACSURL,
SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"),
SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"),
SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"),
SAMLLogoutURL: trimURL(env("SAML_LOGOUT_URL", "")),
WayenLoginURL: env("WAYEN_LOGIN_URL", ""),
WayenTargetURL: env("WAYEN_TARGET_URL", ""),
WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"),
WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"),
WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"),
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")),
WayneAPIBaseURL: trimURL(env("WAYNE_API_BASE_URL", env("WAYNE_INTERNAL_API_BASE_URL", ""))),
WayneAdminUsername: env("WAYNE_ADMIN_USERNAME", ""),
WayneAdminPassword: env("WAYNE_ADMIN_PASSWORD", ""),
WayneTokenTTLMinutes: envInt("WAYNE_TOKEN_TTL_MINUTES", 1440),
WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"),
OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"),
OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""),
OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120),
OIDCIssuer: oidcIssuer,
OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")),
OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")),
OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")),
OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")),
CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"),
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
CloudDMRegisterURL: trimURL(env("CLOUDDM_REGISTER_URL", "")),
CloudDMAPIToken: env("CLOUDDM_API_TOKEN", ""),
AWXBaseURL: trimURL(env("AWX_BASE_URL", "")),
AWXToken: env("AWX_TOKEN", ""),
AWXUsername: env("AWX_USERNAME", ""),
AWXPassword: env("AWX_PASSWORD", ""),
AWXWebhookToken: env("AWX_WEBHOOK_TOKEN", ""),
AWXFactsTemplateID: envUint64("AWX_FACTS_TEMPLATE_ID", 0),
AWXFactsTimeoutSeconds: envInt("AWX_FACTS_TIMEOUT_SECONDS", 45),
RollbackTemplateID: uint64(envInt("DELIVERY_ROLLBACK_TEMPLATE_ID", 0)),
DeliveryServiceToken: env("DELIVERY_SERVICE_TOKEN", ""),
DeliverySchedulerEnabled: envBool("DELIVERY_SCHEDULER_ENABLED", false),
DeliveryDispatchSeconds: envInt("DELIVERY_DISPATCH_SECONDS", 5),
DeliveryCallbackBaseURL: trimURL(env("DELIVERY_CALLBACK_BASE_URL", publicBaseURL)),
DeliveryCredentialSecret: env("DELIVERY_CREDENTIAL_SECRET", env("JWT_SECRET", "change-this-secret")),
ReservationTTLMinutes: envInt("DELIVERY_RESERVATION_TTL_MINUTES", 120),
DeliveryGlobalLimit: envInt("DELIVERY_GLOBAL_LIMIT", 2),
DeliveryTargetLimit: envInt("DELIVERY_TARGET_LIMIT", 2),
DeliveryHostInstanceLimit: envInt("DELIVERY_HOST_INSTANCE_LIMIT", 4),
DeliveryDataDisks: splitCSV(env("DELIVERY_DATA_DISKS", "/data,/disk1,/mnt,/opt/mysql-delivery")),
SINABaseURL: trimURL(env("SINA_BASE_URL", "https://sinai.qiniu.io:443")),
SINAUsername: env("SINA_USERNAME", ""),
SINAPassword: env("SINA_PASSWORD", ""),
AppEnv: env("APP_ENV", "dev"),
HTTPAddr: httpAddr,
PublicBaseURL: publicBaseURL,
MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"),
AutoMigrate: envBool("AUTO_MIGRATE", true),
SSOEnabled: envBool("SSO_ENABLED", true),
JWTSecret: env("JWT_SECRET", "change-this-secret"),
JWTIssuer: env("JWT_ISSUER", "authserver"),
JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120),
SAMLEntityID: samlEntityID,
SAMLACSURL: samlACSURL,
SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"),
SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"),
SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"),
SAMLLogoutURL: trimURL(env("SAML_LOGOUT_URL", "")),
WayenLoginURL: env("WAYEN_LOGIN_URL", ""),
WayenTargetURL: env("WAYEN_TARGET_URL", ""),
WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"),
WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"),
WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"),
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")),
WayneAPIBaseURL: trimURL(env("WAYNE_API_BASE_URL", env("WAYNE_INTERNAL_API_BASE_URL", ""))),
WayneAdminUsername: env("WAYNE_ADMIN_USERNAME", ""),
WayneAdminPassword: env("WAYNE_ADMIN_PASSWORD", ""),
WayneTokenTTLMinutes: envInt("WAYNE_TOKEN_TTL_MINUTES", 1440),
WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"),
OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"),
OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""),
OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120),
OIDCIssuer: oidcIssuer,
OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")),
OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")),
OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")),
OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")),
CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"),
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
CloudDMPublicURL: trimURL(env("CLOUDDM_PUBLIC_URL", "")),
CloudDMLoginURL: trimURL(env("CLOUDDM_LOGIN_URL", "")),
CloudDMAdminUsername: env("CLOUDDM_ADMIN_USERNAME", ""),
CloudDMAdminPassword: env("CLOUDDM_ADMIN_PASSWORD", ""),
CloudDMRegisterURL: trimURL(env("CLOUDDM_REGISTER_URL", "")),
CloudDMAPIToken: env("CLOUDDM_API_TOKEN", ""),
AWXBaseURL: trimURL(env("AWX_BASE_URL", "")),
AWXToken: env("AWX_TOKEN", ""),
AWXUsername: env("AWX_USERNAME", ""),
AWXPassword: env("AWX_PASSWORD", ""),
AWXWebhookToken: env("AWX_WEBHOOK_TOKEN", ""),
AWXFactsTemplateID: envUint64("AWX_FACTS_TEMPLATE_ID", 0),
AWXFactsTimeoutSeconds: envInt("AWX_FACTS_TIMEOUT_SECONDS", 45),
MySQLDeliveryTemplateName: env("DELIVERY_MYSQL_TEMPLATE_NAME", "XINFRA MySQL Native Prototype"),
MySQLInspectTemplateName: env("DELIVERY_MYSQL_INSPECT_TEMPLATE_NAME", "XINFRA MySQL Inspect"),
MySQLInspectTimeoutSeconds: envInt("DELIVERY_MYSQL_INSPECT_TIMEOUT_SECONDS", 90),
RollbackTemplateID: uint64(envInt("DELIVERY_ROLLBACK_TEMPLATE_ID", 0)),
DeliveryServiceToken: env("DELIVERY_SERVICE_TOKEN", ""),
DeliverySchedulerEnabled: envBool("DELIVERY_SCHEDULER_ENABLED", false),
DeliveryDispatchSeconds: envInt("DELIVERY_DISPATCH_SECONDS", 5),
DeliveryCallbackBaseURL: trimURL(env("DELIVERY_CALLBACK_BASE_URL", publicBaseURL)),
DeliveryCredentialSecret: env("DELIVERY_CREDENTIAL_SECRET", env("JWT_SECRET", "change-this-secret")),
ReservationTTLMinutes: envInt("DELIVERY_RESERVATION_TTL_MINUTES", 120),
DeliveryGlobalLimit: envInt("DELIVERY_GLOBAL_LIMIT", 2),
DeliveryTargetLimit: envInt("DELIVERY_TARGET_LIMIT", 2),
DeliveryHostInstanceLimit: envInt("DELIVERY_HOST_INSTANCE_LIMIT", 4),
DeliveryDataDisks: splitCSV(env("DELIVERY_DATA_DISKS", "/data,/disk1,/mnt,/opt/mysql-delivery")),
SINABaseURL: trimURL(env("SINA_BASE_URL", "https://sinai.qiniu.io:443")),
SINAUsername: env("SINA_USERNAME", ""),
SINAPassword: env("SINA_PASSWORD", ""),
}
}
+169
View File
@@ -2,15 +2,20 @@ package handler
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/service"
gmsm2 "github.com/tjfoc/gmsm/sm2"
gmx509 "github.com/tjfoc/gmsm/x509"
"github.com/gin-gonic/gin"
)
@@ -38,6 +43,22 @@ func (h *CloudDMHandler) Login(c *gin.Context) {
return
}
if claims.IsAdmin {
targetURL, err := h.adminLogin(c, targetURL)
if err != nil {
h.writeAudit(c, claims.UserID, claims.Username, "deny", err.Error())
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
h.writeAudit(c, claims.UserID, claims.Username, "allow", "platform admin uses clouddm admin account")
if strings.Contains(c.GetHeader("Accept"), "application/json") {
c.JSON(http.StatusOK, gin.H{"target_url": targetURL})
return
}
c.Redirect(http.StatusFound, targetURL)
return
}
jumpURL, err := h.loginJumpURL(targetURL)
if err != nil {
h.writeAudit(c, claims.UserID, claims.Username, "deny", err.Error())
@@ -97,6 +118,154 @@ func (h *CloudDMHandler) loginJumpURL(targetURL string) (string, error) {
return result.Data, nil
}
func (h *CloudDMHandler) adminLogin(c *gin.Context, targetURL string) (string, error) {
username := strings.TrimSpace(h.cfg.CloudDMAdminUsername)
password := h.cfg.CloudDMAdminPassword
if username == "" || password == "" {
return "", fmt.Errorf("clouddm admin account is not configured")
}
publicKey, err := h.fetchPublicKey(targetURL)
if err != nil {
return "", err
}
encryptedPassword, err := encryptCloudDMPassword(publicKey, password)
if err != nil {
return "", err
}
loginURL := strings.TrimSpace(h.cfg.CloudDMLoginURL)
if loginURL == "" {
loginURL = strings.TrimRight(targetURL, "/") + "/login"
}
body, _ := json.Marshal(gin.H{
"accountType": "SUB_ACCOUNT",
"loginType": "PASSWORD",
"account": username,
"password": encryptedPassword,
})
req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("clouddm admin login failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var result struct {
Success bool `json:"success"`
Data struct {
NeedMore bool `json:"needMore"`
NeedMfa bool `json:"needMfa"`
} `json:"data"`
Msg string `json:"msg"`
MsgContent string `json:"msgContent"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
if !result.Success || result.Data.NeedMore || result.Data.NeedMfa {
reason := strings.TrimSpace(result.MsgContent)
if reason == "" {
reason = strings.TrimSpace(result.Msg)
}
if reason == "" {
reason = "admin login did not complete"
}
return "", fmt.Errorf("clouddm admin login failed: %s", reason)
}
for _, cookie := range resp.Header.Values("Set-Cookie") {
c.Writer.Header().Add("Set-Cookie", cookie)
}
return h.publicSQLURL(), nil
}
func (h *CloudDMHandler) fetchPublicKey(targetURL string) (string, error) {
requestURL := strings.TrimRight(targetURL, "/") + "/api/entry/dmGlobalSettings"
req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewReader([]byte("{}")))
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("clouddm dmGlobalSettings failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var result struct {
Success bool `json:"success"`
Data struct {
PublicKey string `json:"publicKey"`
} `json:"data"`
Msg string `json:"msg"`
MsgContent string `json:"msgContent"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
if !result.Success || strings.TrimSpace(result.Data.PublicKey) == "" {
reason := strings.TrimSpace(result.MsgContent)
if reason == "" {
reason = strings.TrimSpace(result.Msg)
}
if reason == "" {
reason = "empty public key"
}
return "", fmt.Errorf("clouddm dmGlobalSettings failed: %s", reason)
}
return result.Data.PublicKey, nil
}
func encryptCloudDMPassword(publicKey, password string) (string, error) {
pub, err := gmx509.ReadPublicKeyFromHex(strings.TrimSpace(publicKey))
if err != nil {
return "", err
}
ciphertext, err := gmsm2.Encrypt(pub, []byte(password), rand.Reader, gmsm2.C1C3C2)
if err != nil {
return "", err
}
return hex.EncodeToString(ciphertext), nil
}
func (h *CloudDMHandler) publicSQLURL() string {
base := strings.TrimSpace(h.cfg.CloudDMPublicURL)
if base == "" {
if redirectURI, err := url.Parse(strings.TrimSpace(h.cfg.CloudDMRedirectURI)); err == nil && redirectURI.Scheme != "" && redirectURI.Host != "" {
base = redirectURI.Scheme + "://" + redirectURI.Host
}
}
if base == "" {
base = strings.TrimSpace(h.cfg.CloudDMTargetURL)
}
base = strings.TrimRight(base, "/")
if strings.Contains(base, "#") {
return base
}
return base + "/#/sql"
}
func (h *CloudDMHandler) writeAudit(c *gin.Context, userID uint64, username, decision, reason string) {
h.audit.Write(service.AuditEntry{
ActorUserID: userID,
+23
View File
@@ -163,6 +163,29 @@ func (h *DeliveryHandler) MySQLServiceLedger(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *DeliveryHandler) SyncMySQLServiceLedger(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
businessLineID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || businessLineID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid business line id"})
return
}
items, err := h.service.SyncMySQLInstanceStatuses(c.Request.Context(), claims.UserID, claims.IsAdmin, businessLineID)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "business line not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *DeliveryHandler) RevealCredentials(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
+1
View File
@@ -157,6 +157,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
protected.POST("/delivery/mysql", deliveryHandler.CreateMySQL)
protected.GET("/delivery/tasks", deliveryHandler.List)
protected.GET("/delivery/business-lines/:id/mysql-services", deliveryHandler.MySQLServiceLedger)
protected.POST("/delivery/business-lines/:id/mysql-services/sync", deliveryHandler.SyncMySQLServiceLedger)
protected.GET("/delivery/tasks/:id", deliveryHandler.Get)
protected.GET("/delivery/tasks/:id/stream", deliveryHandler.Stream)
protected.POST("/delivery/tasks/:id/credentials/reveal", deliveryHandler.RevealCredentials)
+489 -17
View File
@@ -12,6 +12,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"regexp"
@@ -128,6 +129,24 @@ type MySQLServiceLedgerItem struct {
Namespace string `json:"namespace"`
}
type MySQLInspectResult struct {
ID uint64 `json:"id"`
TaskID string `json:"task_id"`
InstanceName string `json:"instance_name"`
Host string `json:"host"`
Status string `json:"status"`
ServiceState string `json:"service_state"`
PortListening bool `json:"port_listening"`
ConfigExists bool `json:"config_exists"`
InstallExists bool `json:"install_exists"`
BaseExists bool `json:"base_exists"`
DataExists bool `json:"data_exists"`
RunExists bool `json:"run_exists"`
ExpectedDataDir string `json:"expected_data_dir"`
ActualDataDir string `json:"actual_data_dir"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type DeploymentCredentialView struct {
Service string `json:"service"`
InstanceName string `json:"instance_name"`
@@ -373,6 +392,9 @@ func (s *DeliveryService) ListTargets(ctx context.Context, component string) ([]
return nil, err
}
component = strings.ToLower(strings.TrimSpace(component))
if component == "mysql" {
return s.listNamedDeliveryTargets(ctx, templates, s.cfg.MySQLDeliveryTemplateName)
}
var targets []DeliveryTarget
for _, template := range templates {
if template.Inventory == 0 {
@@ -393,11 +415,42 @@ func (s *DeliveryService) ListTargets(ctx context.Context, component string) ([]
return targets, nil
}
func (s *DeliveryService) listNamedDeliveryTargets(ctx context.Context, templates []AWXJobTemplate, name string) ([]DeliveryTarget, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("DELIVERY_MYSQL_TEMPLATE_NAME must be configured")
}
var matched []AWXJobTemplate
for _, template := range templates {
if template.Name == name {
matched = append(matched, template)
}
}
if len(matched) == 0 {
return nil, fmt.Errorf("AWX job template named %q was not found", name)
}
if len(matched) > 1 {
return nil, fmt.Errorf("multiple AWX job templates named %q found", name)
}
template := matched[0]
if template.Inventory == 0 {
return nil, fmt.Errorf("AWX job template %q does not bind an inventory", name)
}
target, err := s.awxDeliveryTarget(ctx, template)
if err != nil {
return nil, err
}
return []DeliveryTarget{target}, nil
}
func (s *DeliveryService) getTarget(ctx context.Context, templateID uint64) (DeliveryTarget, error) {
template, err := s.awx.GetJobTemplate(ctx, templateID)
if err != nil {
return DeliveryTarget{}, fmt.Errorf("deployment target is unavailable: %w", err)
}
if expected := strings.TrimSpace(s.cfg.MySQLDeliveryTemplateName); expected != "" && template.Name != expected {
return DeliveryTarget{}, fmt.Errorf("deployment target must be AWX job template %q", expected)
}
return s.awxDeliveryTarget(ctx, *template)
}
@@ -702,7 +755,7 @@ func ensureInstanceNameAvailable(tx *gorm.DB, businessLineID uint64, component,
var resultCount int64
if err := tx.Model(&model.DeploymentResult{}).
Where("business_line_id = ? AND component = ? AND instance_name = ? AND status = ?", businessLineID, component, instanceName, "active").
Where("business_line_id = ? AND component = ? AND instance_name = ? AND status IN ?", businessLineID, component, instanceName, occupiedDeploymentStatuses()).
Count(&resultCount).Error; err != nil {
return err
}
@@ -722,6 +775,10 @@ var supportedTopologies = map[string]bool{"standalone": true}
const rollbackLaunchTimeout = 2 * time.Minute
func occupiedDeploymentStatuses() []string {
return []string{"active", "running", "stopped", "moved", "unknown"}
}
var supportedCharsets = map[string]bool{"utf8mb4": true, "utf8": true, "gbk": true, "latin1": true}
// 高级参数档位白名单(与 docs/mysql-parameter-selection.md 保持一致)
@@ -869,16 +926,8 @@ func (s *DeliveryService) ListMySQLServiceLedger(ctx context.Context, userID uin
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")
}
if err := s.ensureBusinessLineAccess(ctx, userID, isAdmin, businessLineID); err != nil {
return nil, err
}
var businessLine model.BusinessLine
if err := s.db.WithContext(ctx).First(&businessLine, "id = ?", businessLineID).Error; err != nil {
@@ -886,7 +935,7 @@ func (s *DeliveryService) ListMySQLServiceLedger(ctx context.Context, userID uin
}
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").
Where("business_line_id = ? AND component = ? AND service_type = ? AND status IN ?", businessLineID, "mysql", "database", []string{"active", "running", "stopped", "moved", "unknown"}).
Order("created_at DESC").
Find(&instances).Error; err != nil {
return nil, err
@@ -907,10 +956,25 @@ func (s *DeliveryService) ListMySQLServiceLedger(ctx context.Context, userID uin
}
items := make([]MySQLServiceLedgerItem, 0, len(instances))
for _, instance := range instances {
s.ensureMySQLDeploymentMetadata(ctx, &instance)
status := "健康"
statusClass := "ok"
healthy := 1
if taskStatuses[instance.TaskID] == model.TaskRegisterFailed {
switch instance.Status {
case "stopped":
status = "已关闭"
statusClass = "warn"
healthy = 0
case "moved":
status = "路径变更"
statusClass = "warn"
healthy = 0
case "unknown":
status = "未知"
statusClass = "warn"
healthy = 0
}
if taskStatuses[instance.TaskID] == model.TaskRegisterFailed && healthy == 1 {
status = "注册异常"
statusClass = "warn"
}
@@ -935,6 +999,244 @@ func (s *DeliveryService) ListMySQLServiceLedger(ctx context.Context, userID uin
return items, nil
}
func (s *DeliveryService) SyncMySQLInstanceStatuses(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) ([]MySQLInspectResult, error) {
if businessLineID == 0 {
return nil, fmt.Errorf("business_line_id is required")
}
if err := s.ensureBusinessLineAccess(ctx, userID, isAdmin, businessLineID); err != nil {
return nil, err
}
inspectTemplateName := strings.TrimSpace(s.cfg.MySQLInspectTemplateName)
if inspectTemplateName == "" {
return nil, fmt.Errorf("DELIVERY_MYSQL_INSPECT_TEMPLATE_NAME must be configured")
}
inspectTemplate, err := s.awxJobTemplateByName(ctx, inspectTemplateName)
if 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 IN ?", businessLineID, "mysql", "database", occupiedDeploymentStatuses()).
Order("target_id ASC, created_at DESC").
Find(&instances).Error; err != nil {
return nil, err
}
if len(instances) == 0 {
return []MySQLInspectResult{}, nil
}
byTarget := map[uint64][]model.DeploymentResult{}
for i := range instances {
s.ensureMySQLDeploymentMetadata(ctx, &instances[i])
byTarget[instances[i].TargetID] = append(byTarget[instances[i].TargetID], instances[i])
}
out := make([]MySQLInspectResult, 0, len(instances))
for targetID, group := range byTarget {
targetTemplate, err := s.awx.GetJobTemplate(ctx, targetID)
if err != nil {
return nil, fmt.Errorf("load AWX target template %d: %w", targetID, err)
}
extraInstances := make([]map[string]any, 0, len(group))
hosts := make([]string, 0, len(group))
seenHosts := map[string]struct{}{}
for _, instance := range group {
meta := metadataMap(instance.Metadata)
host := strings.TrimSpace(instance.NodeName)
if host == "" {
host = strings.TrimSpace(instance.Host)
}
if host != "" {
if _, ok := seenHosts[host]; !ok {
hosts = append(hosts, host)
seenHosts[host] = struct{}{}
}
}
extraInstances = append(extraInstances, map[string]any{
"id": instance.ID,
"task_id": instance.TaskID,
"instance_name": instance.InstanceName,
"target_host": host,
"host": instance.Host,
"port": instance.Port,
"data_disk": stringValue(meta["data_disk"]),
"base_dir": stringValue(meta["base_dir"]),
"install_dir": stringValue(meta["install_dir"]),
"data_dir": stringValue(meta["data_dir"]),
"run_dir": stringValue(meta["run_dir"]),
"config_file": stringValue(meta["config_file"]),
"service_name": stringValue(meta["service_name"]),
})
}
sort.Strings(hosts)
job, err := s.awx.Launch(ctx, inspectTemplate.ID, AWXLaunchRequest{
InventoryID: targetTemplate.Inventory,
Limit: strings.Join(hosts, ","),
ExtraVars: map[string]any{
"target_hosts": strings.Join(hosts, ","),
"mysql_instances": extraInstances,
},
})
if err != nil {
return nil, fmt.Errorf("launch AWX MySQL inspect job: %w", err)
}
done, err := s.awx.WaitJob(ctx, strconv.FormatUint(job.ID, 10), time.Duration(s.cfg.MySQLInspectTimeoutSeconds)*time.Second)
if err != nil {
return nil, err
}
stdout, err := s.awx.JobStdout(ctx, strconv.FormatUint(job.ID, 10))
if err != nil {
return nil, err
}
if done.Status != "successful" {
return nil, fmt.Errorf("AWX MySQL inspect job %d finished with status %s: %s", done.ID, done.Status, truncateForEvent(stdout, 1000))
}
results := parseMySQLInspectResults(stdout)
if len(results) == 0 {
return nil, fmt.Errorf("AWX MySQL inspect job %d returned no machine-readable results", job.ID)
}
if err := s.applyMySQLInspectResults(ctx, results); err != nil {
return nil, err
}
out = append(out, results...)
}
return out, nil
}
func (s *DeliveryService) awxJobTemplateByName(ctx context.Context, name string) (*AWXJobTemplate, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("AWX job template name is required")
}
templates, err := s.awx.ListJobTemplates(ctx)
if err != nil {
return nil, err
}
var matched *AWXJobTemplate
for i := range templates {
if templates[i].Name != name {
continue
}
if matched != nil {
return nil, fmt.Errorf("multiple AWX job templates named %q found", name)
}
item := templates[i]
matched = &item
}
if matched == nil {
return nil, fmt.Errorf("AWX job template named %q was not found", name)
}
return matched, nil
}
func (s *DeliveryService) ensureBusinessLineAccess(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) error {
if isAdmin {
return nil
}
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 err
}
if count == 0 {
return fmt.Errorf("user is not authorized for this business line")
}
return nil
}
func parseMySQLInspectResults(stdout string) []MySQLInspectResult {
const marker = "XINFRA_MYSQL_INSPECT_RESULT_B64="
var out []MySQLInspectResult
for _, line := range strings.Split(stdout, "\n") {
idx := strings.Index(line, marker)
if idx < 0 {
continue
}
token := strings.Trim(strings.TrimSpace(line[idx+len(marker):]), "\\\",")
end := 0
for end < len(token) {
ch := token[end]
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '+' || ch == '/' || ch == '=' {
end++
continue
}
break
}
token = token[:end]
rawBytes, err := base64.StdEncoding.DecodeString(token)
if err != nil {
continue
}
var items []MySQLInspectResult
if err := json.Unmarshal(rawBytes, &items); err == nil {
out = append(out, items...)
}
}
return out
}
func (s *DeliveryService) applyMySQLInspectResults(ctx context.Context, results []MySQLInspectResult) error {
now := time.Now()
for _, result := range results {
status := normalizeMySQLInspectStatus(result.Status)
if result.ID == 0 || status == "" {
continue
}
var current model.DeploymentResult
if err := s.db.WithContext(ctx).First(&current, "id = ?", result.ID).Error; err != nil {
return err
}
meta := metadataMap(current.Metadata)
meta["last_probe"] = map[string]any{
"checked_at": now.Format(time.RFC3339),
"status": status,
"host": result.Host,
"service_state": result.ServiceState,
"port_listening": result.PortListening,
"config_exists": result.ConfigExists,
"install_exists": result.InstallExists,
"base_exists": result.BaseExists,
"data_exists": result.DataExists,
"run_exists": result.RunExists,
"expected_data_dir": result.ExpectedDataDir,
"actual_data_dir": result.ActualDataDir,
}
if status == "moved" && result.ActualDataDir != "" {
meta["actual_data_dir"] = result.ActualDataDir
}
result.Metadata = meta
if err := s.db.WithContext(ctx).Model(&model.DeploymentResult{}).
Where("id = ?", result.ID).
Updates(map[string]any{"status": status, "metadata": string(mustJSON(meta)), "updated_at": now}).Error; err != nil {
return err
}
if status == "deleted" {
if err := s.db.WithContext(ctx).Model(&model.ResourceUsage{}).
Where("instance_id = ? AND status = ?", result.ID, "active").
Updates(map[string]any{"status": "released", "released_at": now, "updated_at": now}).Error; err != nil {
return err
}
}
}
return nil
}
func normalizeMySQLInspectStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "running", "stopped", "deleted", "moved", "unknown":
return strings.ToLower(strings.TrimSpace(status))
default:
return "unknown"
}
}
func truncateForEvent(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit] + "..."
}
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, "@")
@@ -1350,7 +1652,7 @@ func (s *DeliveryService) claimAndReserve(ctx context.Context) (*model.DeliveryT
}
var instancePorts []int
if err := tx.Model(&model.DeploymentResult{}).
Where("component = ? AND service_type = ? AND node_name = ? AND status = ?", "mysql", "database", host.Name, "active").
Where("component = ? AND service_type = ? AND node_name = ? AND status IN ?", "mysql", "database", host.Name, occupiedDeploymentStatuses()).
Pluck("port", &instancePorts).Error; err != nil {
return err
}
@@ -1457,6 +1759,115 @@ func mustJSON(value any) []byte {
return raw
}
func metadataMap(raw string) map[string]any {
out := map[string]any{}
if strings.TrimSpace(raw) != "" {
_ = json.Unmarshal([]byte(raw), &out)
}
return out
}
func mysqlDeploymentMetadata(task model.DeliveryTask, payload deliveryPayload) map[string]any {
dataDisk := strings.TrimRight(strings.TrimSpace(payload.DataDisk), "/")
if dataDisk == "" {
dataDisk = "/data"
}
instance := payload.InstanceName
if instance == "" {
instance = task.InstanceName
}
mysqlVersion := payload.MySQLVersion
if mysqlVersion == "" {
mysqlVersion = "8.0"
}
topology := payload.Topology
if topology == "" {
topology = "standalone"
}
timezone := payload.Timezone
if timezone == "" {
timezone = "+08:00"
}
lowerCaseTableNames := 1
if payload.LowerCaseTableNames != nil {
lowerCaseTableNames = *payload.LowerCaseTableNames
}
characterSet := payload.CharacterSet
if characterSet == "" {
characterSet = "utf8mb4"
}
collation := payload.Collation
if collation == "" {
collation = "utf8mb4_general_ci"
}
baseDir := dataDisk + "/mysql-delivery/" + instance
return map[string]any{
"metadata_version": 2,
"component": "mysql",
"service_type": "database",
"business_line_id": task.BusinessLineID,
"target_id": task.TargetID,
"target_type": task.TargetType,
"namespace": payload.Namespace,
"instance_name": instance,
"target_host": task.TargetHost,
"target_host_ip": task.TargetHostIP,
"mysql_port": task.MySQLPort,
"mysql_version": mysqlVersion,
"topology": topology,
"cpu_milli": payload.CPUMilli,
"memory_mi": payload.MemoryMi,
"storage_gi": payload.StorageGi,
"data_disk": dataDisk,
"base_dir": baseDir,
"install_dir": "/opt/mysql-delivery/" + instance,
"data_dir": baseDir + "/data",
"log_dir": baseDir + "/logs",
"binlog_dir": baseDir + "/logs/binlog",
"redo_dir": baseDir + "/logs/redo",
"tmp_dir": baseDir + "/tmp",
"run_dir": "/run/mysql-delivery-" + instance,
"config_file": "/etc/mysql/mysql-delivery/" + instance + ".cnf",
"service_name": "mysql-delivery@" + instance + ".service",
"timezone": timezone,
"lower_case_table_names": lowerCaseTableNames,
"character_set": characterSet,
"collation": collation,
"max_connections": payload.MaxConnections,
"innodb_redo_log_capacity": payload.InnodbRedoLogCapacity,
"innodb_flush_log_at_trx_commit": payload.InnodbFlushLogAtTrxCommit,
"sync_binlog": payload.SyncBinlog,
"innodb_io_capacity": payload.InnodbIOCapacity,
"long_query_time": payload.LongQueryTime,
"binlog_expire_logs_seconds": payload.BinlogExpireLogsSeconds,
"max_binlog_size": payload.MaxBinlogSize,
}
}
func (s *DeliveryService) ensureMySQLDeploymentMetadata(ctx context.Context, result *model.DeploymentResult) map[string]any {
meta := metadataMap(result.Metadata)
if fmt.Sprint(meta["metadata_version"]) == "2" && strings.TrimSpace(stringValue(meta["data_dir"])) != "" && strings.TrimSpace(stringValue(meta["config_file"])) != "" {
return meta
}
var task model.DeliveryTask
if err := s.db.WithContext(ctx).First(&task, "id = ?", result.TaskID).Error; err != nil {
return meta
}
var payload deliveryPayload
if err := json.Unmarshal([]byte(task.ImmutablePayload), &payload); err != nil {
return meta
}
enriched := mysqlDeploymentMetadata(task, payload)
for key, value := range meta {
enriched[key] = value
}
if raw := string(mustJSON(enriched)); raw != result.Metadata {
_ = s.db.WithContext(ctx).Model(result).Update("metadata", raw).Error
result.Metadata = raw
}
return enriched
}
func mysqlReady(ctx context.Context, address string) error {
dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
@@ -1892,6 +2303,7 @@ 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
}
metadata := mysqlDeploymentMetadata(task, payload)
result := model.DeploymentResult{
TaskID: task.ID,
BusinessLineID: task.BusinessLineID,
@@ -1905,7 +2317,7 @@ func (s *DeliveryService) completeTask(ctx context.Context, taskID string) error
Port: task.MySQLPort,
Version: payload.MySQLVersion,
Status: "active",
Metadata: string(mustJSON(map[string]any{"data_disk": payload.DataDisk})),
Metadata: string(mustJSON(metadata)),
}
if result.Component == "" {
result.Component = "mysql"
@@ -1999,6 +2411,56 @@ func cloudDMClientTimeZone(value string) string {
return value
}
func cloudDMDataSourceIDFromResponse(raw []byte) (uint64, bool) {
var value any
if len(bytes.TrimSpace(raw)) == 0 {
return 0, false
}
if err := json.Unmarshal(raw, &value); err != nil {
return 0, false
}
return cloudDMDataSourceIDFromValue(value)
}
func cloudDMDataSourceIDFromValue(value any) (uint64, bool) {
switch typed := value.(type) {
case float64:
if typed <= 0 || typed != float64(uint64(typed)) {
return 0, false
}
return uint64(typed), true
case string:
id, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64)
if err != nil || id == 0 {
return 0, false
}
return id, true
case map[string]any:
for _, key := range []string{"dataSourceId", "datasourceId", "dsId", "id", "data"} {
if id, ok := cloudDMDataSourceIDFromValue(typed[key]); ok {
return id, true
}
}
}
return 0, false
}
func cloudDMMetadata(existing map[string]any, instance model.DeploymentResult, dataSourceID uint64) map[string]any {
meta := map[string]any{}
for key, value := range existing {
meta[key] = value
}
meta["clouddm"] = map[string]any{
"data_source_id": dataSourceID,
"external_resource_id": cloudDMExternalResourceID(instance.ID),
"registered_at": time.Now().Format(time.RFC3339),
"delete_status": "",
"delete_error": "",
"deleted_at": "",
}
return meta
}
func buildCloudDMRegisterRequest(instance model.DeploymentResult, payload deliveryPayload, password string) cloudDMRegisterRequest {
description := strings.TrimSpace(payload.InstanceDesc)
if description == "" {
@@ -2073,8 +2535,18 @@ func (s *DeliveryService) RegisterCloudDM(ctx context.Context, taskID string) er
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("CloudDM returned %s", resp.Status)
return fmt.Errorf("CloudDM returned %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
}
dataSourceID, ok := cloudDMDataSourceIDFromResponse(respBody)
if !ok {
return fmt.Errorf("CloudDM registration response did not include dataSourceId: %s", strings.TrimSpace(string(respBody)))
}
meta := metadataMap(instance.Metadata)
meta = cloudDMMetadata(meta, instance, dataSourceID)
if err := s.db.WithContext(ctx).Model(&instance).Update("metadata", string(mustJSON(meta))).Error; err != nil {
return fmt.Errorf("save CloudDM dataSourceId: %w", err)
}
return nil
}
@@ -2204,7 +2676,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.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 {
if err := tx.Model(&model.DeploymentResult{}).Where("task_id = ? AND component = ? AND service_type = ? AND status IN ?", taskID, "mysql", "database", occupiedDeploymentStatuses()).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 {
+36
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/base64"
"encoding/json"
"testing"
"time"
@@ -88,6 +89,18 @@ func TestValidateDeliveryInput(t *testing.T) {
}
}
func TestParseMySQLInspectResults(t *testing.T) {
payload := `[{"id":7,"task_id":"task-1","instance_name":"mysql-01","host":"k8s-01","status":"running","port_listening":true}]`
stdout := `ok: [k8s-01] => {"msg": "XINFRA_MYSQL_INSPECT_RESULT_B64=` + base64.StdEncoding.EncodeToString([]byte(payload)) + `"}`
items := parseMySQLInspectResults(stdout)
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
if items[0].ID != 7 || items[0].Status != "running" || !items[0].PortListening {
t.Fatalf("unexpected inspect result: %+v", items[0])
}
}
func TestFirstFreeHost(t *testing.T) {
hosts := []targetHost{{Name: "node-a"}, {Name: "node-b"}}
if h := firstFreeHost(hosts, nil, 1); h == nil || h.Name != "node-a" {
@@ -164,6 +177,29 @@ func TestBuildCloudDMRegisterRequest(t *testing.T) {
}
}
func TestCloudDMDataSourceIDFromResponse(t *testing.T) {
tests := []struct {
name string
body string
want uint64
ok bool
}{
{name: "direct data", body: `{"data":123}`, want: 123, ok: true},
{name: "nested data source id", body: `{"code":0,"data":{"dataSourceId":456}}`, want: 456, ok: true},
{name: "nested id", body: `{"success":true,"data":{"id":789}}`, want: 789, ok: true},
{name: "string ds id", body: `{"dsId":"321"}`, want: 321, ok: true},
{name: "missing", body: `{"code":0,"message":"ok"}`, want: 0, ok: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := cloudDMDataSourceIDFromResponse([]byte(tt.body))
if got != tt.want || ok != tt.ok {
t.Fatalf("cloudDMDataSourceIDFromResponse() = %d, %v; want %d, %v", got, ok, tt.want, tt.ok)
}
})
}
}
func TestRollbackExtraVarsTargetsOnlyTheAllocatedInstance(t *testing.T) {
task := &model.DeliveryTask{ID: "task-1", TargetHost: "db-01"}
payload := deliveryPayload{MySQLDeliveryInput: MySQLDeliveryInput{InstanceName: "mysql-a", DataDisk: "/disk1"}}