diff --git a/functionsystem/apps/cli/go.mod b/functionsystem/apps/cli/go.mod index 03a31808c253c4c611c0b72120feb795497cf27a..1287c05409f118135a75af097cef52fafe36a742 100644 --- a/functionsystem/apps/cli/go.mod +++ b/functionsystem/apps/cli/go.mod @@ -4,10 +4,16 @@ go 1.21.4 require ( github.com/agiledragon/gomonkey v2.0.1+incompatible + github.com/agiledragon/gomonkey/v2 v2.13.0 + github.com/olekukonko/tablewriter v0.0.0-00010101000000-000000000000 github.com/smartystreets/goconvey v1.6.4 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.9.0 go.etcd.io/etcd/client/v3 v3.5.11 + golang.org/x/crypto v0.19.0 + golang.org/x/term v0.17.0 + google.golang.org/protobuf v1.34.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -20,9 +26,11 @@ require ( github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.1.0 // indirect go.etcd.io/etcd/api/v3 v3.5.11 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.11 // indirect go.uber.org/multierr v1.10.0 // indirect @@ -34,7 +42,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/grpc v1.59.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/functionsystem/apps/cli/go.sum b/functionsystem/apps/cli/go.sum index 95263f525ddbf6146a52df3f5ddf4f597670e22c..26bd458425465e759c2753afa6e254bf0ab6687c 100644 --- a/functionsystem/apps/cli/go.sum +++ b/functionsystem/apps/cli/go.sum @@ -1,5 +1,129 @@ +cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= +cloud.google.com/go/accessapproval v1.7.1/go.mod h1:JYczztsHRMK7NTXb6Xw+dwbs/WnOJxbo/2mTI+Kgg68= +cloud.google.com/go/accesscontextmanager v1.8.1/go.mod h1:JFJHfvuaTC+++1iL1coPiG1eu5D24db2wXCDWDjIrxo= +cloud.google.com/go/aiplatform v1.48.0/go.mod h1:Iu2Q7sC7QGhXUeOhAj/oCK9a+ULz1O4AotZiqjQ8MYA= +cloud.google.com/go/analytics v0.21.3/go.mod h1:U8dcUtmDmjrmUTnnnRnI4m6zKn/yaA5N9RlEkYFHpQo= +cloud.google.com/go/apigateway v1.6.1/go.mod h1:ufAS3wpbRjqfZrzpvLC2oh0MFlpRJm2E/ts25yyqmXA= +cloud.google.com/go/apigeeconnect v1.6.1/go.mod h1:C4awq7x0JpLtrlQCr8AzVIzAaYgngRqWf9S5Uhg+wWs= +cloud.google.com/go/apigeeregistry v0.7.1/go.mod h1:1XgyjZye4Mqtw7T9TsY4NW10U7BojBvG4RMD+vRDrIw= +cloud.google.com/go/appengine v1.8.1/go.mod h1:6NJXGLVhZCN9aQ/AEDvmfzKEfoYBlfB80/BHiKVputY= +cloud.google.com/go/area120 v0.8.1/go.mod h1:BVfZpGpB7KFVNxPiQBuHkX6Ed0rS51xIgmGyjrAfzsg= +cloud.google.com/go/artifactregistry v1.14.1/go.mod h1:nxVdG19jTaSTu7yA7+VbWL346r3rIdkZ142BSQqhn5E= +cloud.google.com/go/asset v1.14.1/go.mod h1:4bEJ3dnHCqWCDbWJ/6Vn7GVI9LerSi7Rfdi03hd+WTQ= +cloud.google.com/go/assuredworkloads v1.11.1/go.mod h1:+F04I52Pgn5nmPG36CWFtxmav6+7Q+c5QyJoL18Lry0= +cloud.google.com/go/automl v1.13.1/go.mod h1:1aowgAHWYZU27MybSCFiukPO7xnyawv7pt3zK4bheQE= +cloud.google.com/go/baremetalsolution v1.1.1/go.mod h1:D1AV6xwOksJMV4OSlWHtWuFNZZYujJknMAP4Qa27QIA= +cloud.google.com/go/batch v1.3.1/go.mod h1:VguXeQKXIYaeeIYbuozUmBR13AfL4SJP7IltNPS+A4A= +cloud.google.com/go/beyondcorp v1.0.0/go.mod h1:YhxDWw946SCbmcWo3fAhw3V4XZMSpQ/VYfcKGAEU8/4= +cloud.google.com/go/bigquery v1.53.0/go.mod h1:3b/iXjRQGU4nKa87cXeg6/gogLjO8C6PmuM8i5Bi/u4= +cloud.google.com/go/billing v1.16.0/go.mod h1:y8vx09JSSJG02k5QxbycNRrN7FGZB6F3CAcgum7jvGA= +cloud.google.com/go/binaryauthorization v1.6.1/go.mod h1:TKt4pa8xhowwffiBmbrbcxijJRZED4zrqnwZ1lKH51U= +cloud.google.com/go/certificatemanager v1.7.1/go.mod h1:iW8J3nG6SaRYImIa+wXQ0g8IgoofDFRp5UMzaNk1UqI= +cloud.google.com/go/channel v1.16.0/go.mod h1:eN/q1PFSl5gyu0dYdmxNXscY/4Fi7ABmeHCJNf/oHmc= +cloud.google.com/go/cloudbuild v1.13.0/go.mod h1:lyJg7v97SUIPq4RC2sGsz/9tNczhyv2AjML/ci4ulzU= +cloud.google.com/go/clouddms v1.6.1/go.mod h1:Ygo1vL52Ov4TBZQquhz5fiw2CQ58gvu+PlS6PVXCpZI= +cloud.google.com/go/cloudtasks v1.12.1/go.mod h1:a9udmnou9KO2iulGscKR0qBYjreuX8oHwpmFsKspEvM= +cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/contactcenterinsights v1.10.0/go.mod h1:bsg/R7zGLYMVxFFzfh9ooLTruLRCG9fnzhH9KznHhbM= +cloud.google.com/go/container v1.24.0/go.mod h1:lTNExE2R7f+DLbAN+rJiKTisauFCaoDq6NURZ83eVH4= +cloud.google.com/go/containeranalysis v0.10.1/go.mod h1:Ya2jiILITMY68ZLPaogjmOMNkwsDrWBSTyBubGXO7j0= +cloud.google.com/go/datacatalog v1.16.0/go.mod h1:d2CevwTG4yedZilwe+v3E3ZBDRMobQfSG/a6cCCN5R4= +cloud.google.com/go/dataflow v0.9.1/go.mod h1:Wp7s32QjYuQDWqJPFFlnBKhkAtiFpMTdg00qGbnIHVw= +cloud.google.com/go/dataform v0.8.1/go.mod h1:3BhPSiw8xmppbgzeBbmDvmSWlwouuJkXsXsb8UBih9M= +cloud.google.com/go/datafusion v1.7.1/go.mod h1:KpoTBbFmoToDExJUso/fcCiguGDk7MEzOWXUsJo0wsI= +cloud.google.com/go/datalabeling v0.8.1/go.mod h1:XS62LBSVPbYR54GfYQsPXZjTW8UxCK2fkDciSrpRFdY= +cloud.google.com/go/dataplex v1.9.0/go.mod h1:7TyrDT6BCdI8/38Uvp0/ZxBslOslP2X2MPDucliyvSE= +cloud.google.com/go/dataproc/v2 v2.0.1/go.mod h1:7Ez3KRHdFGcfY7GcevBbvozX+zyWGcwLJvvAMwCaoZ4= +cloud.google.com/go/dataqna v0.8.1/go.mod h1:zxZM0Bl6liMePWsHA8RMGAfmTG34vJMapbHAxQ5+WA8= +cloud.google.com/go/datastore v1.13.0/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1EoLgSp6/N70= +cloud.google.com/go/datastream v1.10.0/go.mod h1:hqnmr8kdUBmrnk65k5wNRoHSCYksvpdZIcZIEl8h43Q= +cloud.google.com/go/deploy v1.13.0/go.mod h1:tKuSUV5pXbn67KiubiUNUejqLs4f5cxxiCNCeyl0F2g= +cloud.google.com/go/dialogflow v1.40.0/go.mod h1:L7jnH+JL2mtmdChzAIcXQHXMvQkE3U4hTaNltEuxXn4= +cloud.google.com/go/dlp v1.10.1/go.mod h1:IM8BWz1iJd8njcNcG0+Kyd9OPnqnRNkDV8j42VT5KOI= +cloud.google.com/go/documentai v1.22.0/go.mod h1:yJkInoMcK0qNAEdRnqY/D5asy73tnPe88I1YTZT+a8E= +cloud.google.com/go/domains v0.9.1/go.mod h1:aOp1c0MbejQQ2Pjf1iJvnVyT+z6R6s8pX66KaCSDYfE= +cloud.google.com/go/edgecontainer v1.1.1/go.mod h1:O5bYcS//7MELQZs3+7mabRqoWQhXCzenBu0R8bz2rwk= +cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU= +cloud.google.com/go/essentialcontacts v1.6.2/go.mod h1:T2tB6tX+TRak7i88Fb2N9Ok3PvY3UNbUsMag9/BARh4= +cloud.google.com/go/eventarc v1.13.0/go.mod h1:mAFCW6lukH5+IZjkvrEss+jmt2kOdYlN8aMx3sRJiAI= +cloud.google.com/go/filestore v1.7.1/go.mod h1:y10jsorq40JJnjR/lQ8AfFbbcGlw3g+Dp8oN7i7FjV4= +cloud.google.com/go/firestore v1.12.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4= +cloud.google.com/go/functions v1.15.1/go.mod h1:P5yNWUTkyU+LvW/S9O6V+V423VZooALQlqoXdoPz5AE= +cloud.google.com/go/gkebackup v1.3.0/go.mod h1:vUDOu++N0U5qs4IhG1pcOnD1Mac79xWy6GoBFlWCWBU= +cloud.google.com/go/gkeconnect v0.8.1/go.mod h1:KWiK1g9sDLZqhxB2xEuPV8V9NYzrqTUmQR9shJHpOZw= +cloud.google.com/go/gkehub v0.14.1/go.mod h1:VEXKIJZ2avzrbd7u+zeMtW00Y8ddk/4V9511C9CQGTY= +cloud.google.com/go/gkemulticloud v1.0.0/go.mod h1:kbZ3HKyTsiwqKX7Yw56+wUGwwNZViRnxWK2DVknXWfw= +cloud.google.com/go/gsuiteaddons v1.6.1/go.mod h1:CodrdOqRZcLp5WOwejHWYBjZvfY0kOphkAKpF/3qdZY= +cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= +cloud.google.com/go/iap v1.8.1/go.mod h1:sJCbeqg3mvWLqjZNsI6dfAtbbV1DL2Rl7e1mTyXYREQ= +cloud.google.com/go/ids v1.4.1/go.mod h1:np41ed8YMU8zOgv53MMMoCntLTn2lF+SUzlM+O3u/jw= +cloud.google.com/go/iot v1.7.1/go.mod h1:46Mgw7ev1k9KqK1ao0ayW9h0lI+3hxeanz+L1zmbbbk= +cloud.google.com/go/kms v1.15.0/go.mod h1:c9J991h5DTl+kg7gi3MYomh12YEENGrf48ee/N/2CDM= +cloud.google.com/go/language v1.10.1/go.mod h1:CPp94nsdVNiQEt1CNjF5WkTcisLiHPyIbMhvR8H2AW0= +cloud.google.com/go/lifesciences v0.9.1/go.mod h1:hACAOd1fFbCGLr/+weUKRAJas82Y4vrL3O5326N//Wc= +cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= +cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= +cloud.google.com/go/managedidentities v1.6.1/go.mod h1:h/irGhTN2SkZ64F43tfGPMbHnypMbu4RB3yl8YcuEak= +cloud.google.com/go/maps v1.4.0/go.mod h1:6mWTUv+WhnOwAgjVsSW2QPPECmW+s3PcRyOa9vgG/5s= +cloud.google.com/go/mediatranslation v0.8.1/go.mod h1:L/7hBdEYbYHQJhX2sldtTO5SZZ1C1vkapubj0T2aGig= +cloud.google.com/go/memcache v1.10.1/go.mod h1:47YRQIarv4I3QS5+hoETgKO40InqzLP6kpNLvyXuyaA= +cloud.google.com/go/metastore v1.12.0/go.mod h1:uZuSo80U3Wd4zi6C22ZZliOUJ3XeM/MlYi/z5OAOWRA= +cloud.google.com/go/monitoring v1.15.1/go.mod h1:lADlSAlFdbqQuwwpaImhsJXu1QSdd3ojypXrFSMr2rM= +cloud.google.com/go/networkconnectivity v1.12.1/go.mod h1:PelxSWYM7Sh9/guf8CFhi6vIqf19Ir/sbfZRUwXh92E= +cloud.google.com/go/networkmanagement v1.8.0/go.mod h1:Ho/BUGmtyEqrttTgWEe7m+8vDdK74ibQc+Be0q7Fof0= +cloud.google.com/go/networksecurity v0.9.1/go.mod h1:MCMdxOKQ30wsBI1eI659f9kEp4wuuAueoC9AJKSPWZQ= +cloud.google.com/go/notebooks v1.9.1/go.mod h1:zqG9/gk05JrzgBt4ghLzEepPHNwE5jgPcHZRKhlC1A8= +cloud.google.com/go/optimization v1.4.1/go.mod h1:j64vZQP7h9bO49m2rVaTVoNM0vEBEN5eKPUPbZyXOrk= +cloud.google.com/go/orchestration v1.8.1/go.mod h1:4sluRF3wgbYVRqz7zJ1/EUNc90TTprliq9477fGobD8= +cloud.google.com/go/orgpolicy v1.11.1/go.mod h1:8+E3jQcpZJQliP+zaFfayC2Pg5bmhuLK755wKhIIUCE= +cloud.google.com/go/osconfig v1.12.1/go.mod h1:4CjBxND0gswz2gfYRCUoUzCm9zCABp91EeTtWXyz0tE= +cloud.google.com/go/oslogin v1.10.1/go.mod h1:x692z7yAue5nE7CsSnoG0aaMbNoRJRXO4sn73R+ZqAs= +cloud.google.com/go/phishingprotection v0.8.1/go.mod h1:AxonW7GovcA8qdEk13NfHq9hNx5KPtfxXNeUxTDxB6I= +cloud.google.com/go/policytroubleshooter v1.8.0/go.mod h1:tmn5Ir5EToWe384EuboTcVQT7nTag2+DuH3uHmKd1HU= +cloud.google.com/go/privatecatalog v0.9.1/go.mod h1:0XlDXW2unJXdf9zFz968Hp35gl/bhF4twwpXZAW50JA= +cloud.google.com/go/pubsub v1.33.0/go.mod h1:f+w71I33OMyxf9VpMVcZbnG5KSUkCOUHYpFd5U1GdRc= +cloud.google.com/go/pubsublite v1.8.1/go.mod h1:fOLdU4f5xldK4RGJrBMm+J7zMWNj/k4PxwEZXy39QS0= +cloud.google.com/go/recaptchaenterprise/v2 v2.7.2/go.mod h1:kR0KjsJS7Jt1YSyWFkseQ756D45kaYNTlDPPaRAvDBU= +cloud.google.com/go/recommendationengine v0.8.1/go.mod h1:MrZihWwtFYWDzE6Hz5nKcNz3gLizXVIDI/o3G1DLcrE= +cloud.google.com/go/recommender v1.10.1/go.mod h1:XFvrE4Suqn5Cq0Lf+mCP6oBHD/yRMA8XxP5sb7Q7gpA= +cloud.google.com/go/redis v1.13.1/go.mod h1:VP7DGLpE91M6bcsDdMuyCm2hIpB6Vp2hI090Mfd1tcg= +cloud.google.com/go/resourcemanager v1.9.1/go.mod h1:dVCuosgrh1tINZ/RwBufr8lULmWGOkPS8gL5gqyjdT8= +cloud.google.com/go/resourcesettings v1.6.1/go.mod h1:M7mk9PIZrC5Fgsu1kZJci6mpgN8o0IUzVx3eJU3y4Jw= +cloud.google.com/go/retail v1.14.1/go.mod h1:y3Wv3Vr2k54dLNIrCzenyKG8g8dhvhncT2NcNjb/6gE= +cloud.google.com/go/run v1.2.0/go.mod h1:36V1IlDzQ0XxbQjUx6IYbw8H3TJnWvhii963WW3B/bo= +cloud.google.com/go/scheduler v1.10.1/go.mod h1:R63Ldltd47Bs4gnhQkmNDse5w8gBRrhObZ54PxgR2Oo= +cloud.google.com/go/secretmanager v1.11.1/go.mod h1:znq9JlXgTNdBeQk9TBW/FnR/W4uChEKGeqQWAJ8SXFw= +cloud.google.com/go/security v1.15.1/go.mod h1:MvTnnbsWnehoizHi09zoiZob0iCHVcL4AUBj76h9fXA= +cloud.google.com/go/securitycenter v1.23.0/go.mod h1:8pwQ4n+Y9WCWM278R8W3nF65QtY172h4S8aXyI9/hsQ= +cloud.google.com/go/servicedirectory v1.11.0/go.mod h1:Xv0YVH8s4pVOwfM/1eMTl0XJ6bzIOSLDt8f8eLaGOxQ= +cloud.google.com/go/shell v1.7.1/go.mod h1:u1RaM+huXFaTojTbW4g9P5emOrrmLE69KrxqQahKn4g= +cloud.google.com/go/spanner v1.47.0/go.mod h1:IXsJwVW2j4UKs0eYDqodab6HgGuA1bViSqW4uH9lfUI= +cloud.google.com/go/speech v1.19.0/go.mod h1:8rVNzU43tQvxDaGvqOhpDqgkJTFowBpDvCJ14kGlJYo= +cloud.google.com/go/storagetransfer v1.10.0/go.mod h1:DM4sTlSmGiNczmV6iZyceIh2dbs+7z2Ayg6YAiQlYfA= +cloud.google.com/go/talent v1.6.2/go.mod h1:CbGvmKCG61mkdjcqTcLOkb2ZN1SrQI8MDyma2l7VD24= +cloud.google.com/go/texttospeech v1.7.1/go.mod h1:m7QfG5IXxeneGqTapXNxv2ItxP/FS0hCZBwXYqucgSk= +cloud.google.com/go/tpu v1.6.1/go.mod h1:sOdcHVIgDEEOKuqUoi6Fq53MKHJAtOwtz0GuKsWSH3E= +cloud.google.com/go/trace v1.10.1/go.mod h1:gbtL94KE5AJLH3y+WVpfWILmqgc6dXcqgNXdOPAQTYk= +cloud.google.com/go/translate v1.8.2/go.mod h1:d1ZH5aaOA0CNhWeXeC8ujd4tdCFw8XoNWRljklu5RHs= +cloud.google.com/go/video v1.19.0/go.mod h1:9qmqPqw/Ib2tLqaeHgtakU+l5TcJxCJbhFXM7UJjVzU= +cloud.google.com/go/videointelligence v1.11.1/go.mod h1:76xn/8InyQHarjTWsBR058SmlPCwQjgcvoW0aZykOvo= +cloud.google.com/go/vision/v2 v2.7.2/go.mod h1:jKa8oSYBWhYiXarHPvP4USxYANYUEdEsQrloLjrSwJU= +cloud.google.com/go/vmmigration v1.7.1/go.mod h1:WD+5z7a/IpZ5bKK//YmT9E047AD+rjycCAvyMxGJbro= +cloud.google.com/go/vmwareengine v1.0.0/go.mod h1:Px64x+BvjPZwWuc4HdmVhoygcXqEkGHXoa7uyfTgSI0= +cloud.google.com/go/vpcaccess v1.7.1/go.mod h1:FogoD46/ZU+JUBX9D606X21EnxiszYi2tArQwLY4SXs= +cloud.google.com/go/webrisk v1.9.1/go.mod h1:4GCmXKcOa2BZcZPn6DCEvE7HypmEJcJkr4mtM+sqYPc= +cloud.google.com/go/websecurityscanner v1.6.1/go.mod h1:Njgaw3rttgRHXzwCB8kgCYqv5/rGpFCsBOvPbYgszpg= +cloud.google.com/go/workflows v1.11.1/go.mod h1:Z+t10G1wF7h8LgdY/EmRcQY8ptBD/nvofaL6FqlET6g= github.com/agiledragon/gomonkey v2.0.1+incompatible h1:DIQT3ZshgGz9pTwBddRSZWDutIRPx2d7UzmjzgWo9q0= github.com/agiledragon/gomonkey v2.0.1+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= +github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo= +github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= @@ -8,24 +132,41 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g= +github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= @@ -35,6 +176,7 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -47,12 +189,14 @@ go.etcd.io/etcd/client/pkg/v3 v3.5.11 h1:bT2xVspdiCj2910T0V+/KHcVKjkUrCZVtk8J2JF go.etcd.io/etcd/client/pkg/v3 v3.5.11/go.mod h1:seTzl2d9APP8R5Y2hFL3NVlD6qC/dOT+3kvrqPyTas4= go.etcd.io/etcd/client/v3 v3.5.11 h1:ajWtgoNSZJ1gmS8k+icvPtqsqEav+iUorF7b0qozgUU= go.etcd.io/etcd/client/v3 v3.5.11/go.mod h1:a6xQUEqFJ8vztO1agJh/KQKOMfFI8og52ZconzcDJwE= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -60,10 +204,12 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= @@ -77,6 +223,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY= google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q= @@ -94,3 +241,4 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/functionsystem/apps/cli/internal/debug/info_instance.go b/functionsystem/apps/cli/internal/debug/info_instance.go new file mode 100755 index 0000000000000000000000000000000000000000..c2d6dd4ef8d69fae4d55e72f2c1371fe6277e177 --- /dev/null +++ b/functionsystem/apps/cli/internal/debug/info_instance.go @@ -0,0 +1,206 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package debug + +import ( + "bufio" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "time" + + "github.com/olekukonko/tablewriter" + "google.golang.org/protobuf/proto" + + "cli/internal/pb" + "cli/pkg/cmdio" + "cli/utils" + "cli/utils/colorprint" +) + +// InstanceInfo for debug instance info +type InstanceInfo struct { + InstanceID string `json:"instanceID"` + PID int32 `json:"pid"` + DebugServer string `json:"debugServer"` + Status string `json:"status"` +} + +var debugInstanceInfosMap map[string]InstanceInfo + +var httpClient *http.Client + +var pageSize = 5 + +var printedFields = []string{"InstanceID", "Status"} + +func init() { + httpClient = &http.Client{ + Timeout: 30 * time.Minute, // 连接超时时间 + Transport: &http.Transport{ + MaxIdleConns: 10, // 最大空闲连接数 + MaxIdleConnsPerHost: 5, // 每个主机最大空闲连接数 + MaxConnsPerHost: 10, // 每个主机最大连接数 + IdleConnTimeout: 30 * time.Second, // 空闲连接的超时时间 + TLSHandshakeTimeout: 30 * time.Minute, // 限制TLS握手的时间 + }, + } +} + +const listDebugInstPath = "/instance-manager/query-debug-instances" + +func pageOutDebugInstanceInfos(cmdIO *cmdio.CmdIO) { + err := queryDebugInstanceInfo(cmdIO) + if err != nil { + return + } + var values []InstanceInfo + for _, v := range debugInstanceInfosMap { + values = append(values, v) + } + dataStr, err := transStructsToString(values, printedFields) + if err != nil { + return + } + + printTable(dataStr, cmdIO, printedFields) +} + +// 执行info instance后通过restful接口查询instance信息 +func queryDebugInstanceInfo(cmdIO *cmdio.CmdIO) error { + url := "http://" + MasterInfo.MasterIP + ":" + MasterInfo.GlobalSchedulerPort + body, err := requestFunctionMaster(url, listDebugInstPath) + if err != nil { + colorprint.PrintFail(cmdIO.Out, "request master to get debug info failed: ", err.Error(), "\n") + return err + } + var debugInst pb.QueryDebugInstanceInfosResponse + err = proto.Unmarshal(body, &debugInst) + if err != nil { + colorprint.PrintFail(cmdIO.Out, "response body to QueryDebugInstancesInfoResponse failed: ", err.Error(), "\n") + return err + } + pbToInstanceInfos(debugInst.DebugInstanceInfos) + return nil +} + +// 请求master获取序列化数据 +func requestFunctionMaster(url string, path string) ([]byte, error) { + req, err := http.NewRequest("GET", url+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Type", "protobuf") + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, nil +} + +// proto消息体反序列化并转存到DebugInstanceInfosMap中 +func pbToInstanceInfos(pbInfos []*pb.DebugInstanceInfo) { + debugInstanceInfosMap = make(map[string]InstanceInfo) + for _, pbInfo := range pbInfos { + instId := pbInfo.InstanceID + info := InstanceInfo{ + InstanceID: instId, + PID: pbInfo.Pid, + DebugServer: pbInfo.DebugServer, + Status: pbInfo.Status, + } + debugInstanceInfosMap[instId] = info + } +} + +// 结构体转为长字符串,结构体字符串之间用","进行分割 +func transStructsToString(arr interface{}, fieldNames []string) (string, error) { + val := reflect.ValueOf(arr) + if val.Kind() != reflect.Slice { + return "", fmt.Errorf("expected a slice, got %s", val.Kind()) + } + var result []string + for i := 0; i < val.Len(); i++ { + structVal := val.Index(i) + if structVal.Kind() != reflect.Struct { + return "", fmt.Errorf("expected struct element, got %s", structVal.Kind()) + } + var fields []string + for _, fieldName := range fieldNames { + field := structVal.FieldByName(fieldName) + if !field.IsValid() { + return "", fmt.Errorf("field %s not found in struct", fieldName) + } + fields = append(fields, fmt.Sprintf("%v", field.Interface())) + } + result = append(result, strings.Join(fields, ",")) + } + return strings.Join(result, "\n"), nil +} + +func printTable(dataStr string, cmdIO *cmdio.CmdIO, tableHeader []string) { + dataLines := strings.Split(strings.TrimSpace(dataStr), "\n") + // 计算总页数 + pageNum := (len(dataLines) + pageSize - 1) / pageSize + // 绑定标准输出 + table := utils.NewTable(cmdIO.Out, true, true, true) + table.SetAlignment(tablewriter.ALIGN_CENTER) + table.SetHeader(tableHeader) + input := "c" // 执行info instance后打印第一页的info + var err error + reader := bufio.NewReader(cmdIO.In) + for i := 0; i < pageNum; i++ { + pageStart := i * pageSize + pageEnd := pageStart + pageSize + if pageEnd > len(dataLines) { + pageEnd = len(dataLines) + } + if input == "c" || input == "continue" { + for j := pageStart; j < pageEnd; j++ { + table.Append(strings.Split(dataLines[j], ",")) + } + table.Render() + table.ClearRows() + } else if input == "q" || input == "quit" { + break + } else { + colorprint.PrintInteractive(cmdIO.Out, "Invalid cmd. Type q or quit to quit, "+ + "c or continue to show next page.\n") + i-- // 防止在输错命令后下一页被跳过 + } + // 判断是否还有完成所有instance info浏览 + if pageEnd == len(dataLines) { + break + } + msg := fmt.Sprintf("Page [%d/%d]. Type q or quit to quit, c or continue to show next page.\n", i, pageNum) + colorprint.PrintInteractive(cmdIO.Out, msg) + // 获取用户接下来的指令输入 + input, err = reader.ReadString('\n') + if err != nil { + colorprint.PrintFail(cmdIO.Out, "Failed to read input: ", err.Error(), "\n") + break + } + input = strings.ToLower(strings.TrimSpace(input)) + } +} diff --git a/functionsystem/apps/cli/internal/debug/info_instance_test.go b/functionsystem/apps/cli/internal/debug/info_instance_test.go new file mode 100755 index 0000000000000000000000000000000000000000..8e87b2d052ce887d31d6eeeedc92574addfad27d --- /dev/null +++ b/functionsystem/apps/cli/internal/debug/info_instance_test.go @@ -0,0 +1,93 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package debug + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" + + message "cli/internal/pb" + "cli/pkg/cmdio" + "cli/utils" +) + +func TestTransStructsToString(t *testing.T) { + infos := []InstanceInfo{ + {"inst1", 101, "127.0.0.1:8080", "S"}, + {"inst2", 102, "127.0.0.1:8081", "R"}, + {"inst3", 103, "127.0.0.1:8082", "S"}, + } + + printedFields := []string{"InstanceID", "Status"} + toString, _ := transStructsToString(infos, printedFields) + assert.Equal(t, "inst1,S\ninst2,R\ninst3,S", toString) +} + +func TestQueryDebugInstanceInfo(t *testing.T) { + info := &message.DebugInstanceInfo{ + InstanceID: "instance1", + Pid: 101, + DebugServer: "127.0.0.1:8080", + Status: "R", + } + debugInstanceInfos := []*message.DebugInstanceInfo{info} + mockRsp := message.QueryDebugInstanceInfosResponse{ + RequestID: "req1", + Code: 0, + DebugInstanceInfos: debugInstanceInfos, + } + // 创建一个处理 HTTP 请求的路由器 + mux := http.NewServeMux() + mux.HandleFunc("/instance-manager/query-debug-instances", func(w http.ResponseWriter, r *http.Request) { + data, err := proto.Marshal(&mockRsp) + if err != nil { + http.Error(w, "Failed to marshal protobuf", http.StatusInternalServerError) + return + } + // 设置响应类型为 "application/x-protobuf" + w.Header().Set("Content-Type", "application/x-protobuf") + // 写入响应数据 + w.Write(data) + }) + // 创建一个模拟 HTTP 服务器 + server := httptest.NewServer(mux) + defer server.Close() + + cmdIO := &cmdio.CmdIO{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + } + // server.URL eg: http://127.0.0.1:39341 + parts := strings.Split(server.URL, ":") + MasterInfo = &utils.MasterInfo{ + MasterIP: parts[1][2:], + GlobalSchedulerPort: parts[2], + } + + err := queryDebugInstanceInfo(cmdIO) + + assert.Nil(t, err) + assert.Contains(t, debugInstanceInfosMap, "instance1") + assert.Equal(t, debugInstanceInfosMap["instance1"].PID, int32(101)) +} diff --git a/functionsystem/apps/cli/internal/debug/yr_debug.go b/functionsystem/apps/cli/internal/debug/yr_debug.go new file mode 100755 index 0000000000000000000000000000000000000000..20a15a068103e3c855fe12b3ca1cd64bfd4ed542 --- /dev/null +++ b/functionsystem/apps/cli/internal/debug/yr_debug.go @@ -0,0 +1,236 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package debug is debug cmd of debug +package debug + +import ( + "bufio" + "fmt" + "os/exec" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "cli/constant" + "cli/pkg/cmdio" + "cli/utils" + "cli/utils/colorprint" +) + +// debugOptions 结构体用于存储命令行参数和相关配置 +type debugOptions struct { + cmdIO *cmdio.CmdIO + master string +} + +const ( + // 调试 Shell 的提示符 + promptString = "(yrdb) " + // 拆分命令和参数 + splitpart = 2 +) + +var ( + opts debugOptions + // MasterInfo 存储 master.info 文件的内容 + MasterInfo *utils.MasterInfo +) + +var yrDebugCmd = &cobra.Command{ + Use: "debug", + Short: fmt.Sprintf("Enter the %s debug shell", constant.PlatformName), + Long: fmt.Sprintf("Enter the %s debug shell", constant.PlatformName), + Example: utils.RawFormat(fmt.Sprintf(` + %s debug in interactive mode: + $ %s debug + + %s debug with specified file path: 'master.info': + $ %s debug --master master.info + `, constant.CliName, constant.CliName, constant.CliName, constant.CliName)), + Args: utils.NoArgs, + RunE: yrDebug, +} + +// CommandHandler 定义命令处理函数的类型 +type CommandHandler func(*cmdio.CmdIO, []string) error + +// commandTable 定义命令表 +var commandTable = map[string]CommandHandler{ + "help": handleHelp, + "h": handleHelp, + "quit": handleQuit, + "q": handleQuit, + "instance": handleInstance, + "i": handleInstance, + "info": handleInfo, +} + +// InitCMD 初始化 debug 命令 +func InitCMD(cio *cmdio.CmdIO) *cobra.Command { + opts.cmdIO = cio + MasterInfo = &utils.MasterInfo{} // 初始化 MasterInfo + debugInstanceInfosMap = make(map[string]InstanceInfo) // 初始化 debugInstanceInfosMap + yrDebugCmd.Flags().StringVarP(&opts.master, "master", "m", "", "Specify the 'master.info' file path") + return yrDebugCmd +} + +// yrDebug 是 debug 命令的执行函数 +func yrDebug(cmd *cobra.Command, args []string) error { + masterPath := opts.master + if masterPath == "" { + masterPath = constant.DefaultYuanRongCurrentMasterInfoPath + } + var err error + MasterInfo, err = utils.GetMasterInfoFromFile(masterPath) + if err != nil { + colorprint.PrintFail(opts.cmdIO.Out, "failed to load master.info: ", err.Error(), "\n") + return err + } + startDebugShell() + return nil +} + +// startDebugShell 启动调试 Shell +func startDebugShell() { + colorprint.PrintInteractive(opts.cmdIO.Out, "Type 'help' or 'h' for help, 'quit' or 'q' to exit.\n") + reader := bufio.NewReader(opts.cmdIO.In) // 使用 bufio.NewReader 读取输入 + for { + colorprint.PrintInteractive(opts.cmdIO.Out, promptString) // 提示符 + + // 使用 bufio.NewReader 读取用户输入 + input, err := reader.ReadString('\n') + if err != nil { + colorprint.PrintFail(opts.cmdIO.Out, "failed to read input: ", err.Error(), "\n") + continue + } + input = strings.TrimSpace(input) // 去除多余的空白字符 + + // 将输入拆分为命令和参数 + parts := strings.Fields(input) + if len(parts) == 0 { + continue // 如果没有输入任何内容,跳过 + } + + // 获取命令处理函数 + handler, ok := commandTable[strings.ToLower(parts[0])] + if !ok { + colorprint.PrintFail(opts.cmdIO.Out, "unknown command: ", input, "\n") + colorprint.PrintInteractive(opts.cmdIO.Out, "Type 'help' or 'h' for a list of available commands.\n") + continue + } + + // 调用命令处理函数 + if err := handler(opts.cmdIO, parts); err != nil { + if err.Error() == "exit" { + // 如果返回的错误是 "exit",则退出循环 + return + } + colorprint.PrintFail(opts.cmdIO.Out, err.Error(), "\n", "") + } + } +} + +// handleHelp 处理 help 命令 +func handleHelp(cio *cmdio.CmdIO, parts []string) error { + colorprint.PrintSuccess(opts.cmdIO.Out, "Available commands:\n", "") + colorprint.PrintSuccess(opts.cmdIO.Out, " help/h: Show this help message\n", "") + colorprint.PrintSuccess(opts.cmdIO.Out, " instance/i : Attach to the specified remote instance\n", "") + colorprint.PrintSuccess(opts.cmdIO.Out, " info instance/i: Show all instance ids and process status\n", "") + colorprint.PrintSuccess(opts.cmdIO.Out, " quit/q: Exit the debug shell\n", "") + return nil +} + +// handleQuit 处理 quit 命令 +func handleQuit(cio *cmdio.CmdIO, parts []string) error { + colorprint.PrintSuccess(opts.cmdIO.Out, "Exiting debug shell.\n", "") + return fmt.Errorf("exit") // 返回错误以退出循环 +} + +// handleInstance 处理 instance 命令 +func handleInstance(cio *cmdio.CmdIO, parts []string) error { + if len(parts) != splitpart { + return fmt.Errorf("invalid command. Usage: instance ") + } + + instanceID := parts[1] + colorprint.PrintInteractive(opts.cmdIO.Out, fmt.Sprintf("Attaching to instance ID: %s\n", instanceID)) + + // 从 debugInstanceInfosMap 中获取实例信息 + instanceInfo, ok := debugInstanceInfosMap[instanceID] + if !ok { + // 发起一次查询,刷新 debugInstanceInfosMap + if err := queryDebugInstanceInfo(cio); err != nil { + return fmt.Errorf("failed to query debug instance info, err: %v", err) + } + if instanceInfo, ok = debugInstanceInfosMap[instanceID]; !ok { + return fmt.Errorf("failed to find instance ID %s", instanceID) + } + } + + // 启动 GDB 客户端并连接到 gdbserver + if err := startGDBClient(instanceInfo.DebugServer, strconv.Itoa(int(instanceInfo.PID))); err != nil { + return err + } + + return nil +} + +// startGDBClient 启动 GDB 客户端并连接到 gdbserver +func startGDBClient(gdbserverAddress string, pid string) error { + // 构造 GDB 客户端命令 + cmd := exec.Command( + "gdb", + "-ex", "set pagination off", + "-ex", fmt.Sprintf("target extended-remote %s", gdbserverAddress), + "-ex", fmt.Sprintf("attach %s", pid), + "-ex", "handle SIGSTOP nostop", + ) + + // 将 GDB 的标准输入、输出和错误重定向到 opts.cmdIO + cmd.Stdin = opts.cmdIO.In + cmd.Stdout = opts.cmdIO.Out + cmd.Stderr = opts.cmdIO.ErrOut + + // 启动 GDB 客户端 + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start GDB client: %v", err) + } + + // 等待 GDB 客户端退出 + if err := cmd.Wait(); err != nil { + return fmt.Errorf("GDB client exited with error: %v", err) + } + + return nil +} + +// handleInfo 处理 info 命令 +func handleInfo(cio *cmdio.CmdIO, parts []string) error { + if len(parts) < splitpart { + return fmt.Errorf("invalid command. Usage: info ") + } + subcommand := strings.ToLower(parts[1]) + switch subcommand { + case "instance", "i": + pageOutDebugInstanceInfos(cio) + return nil + default: + return fmt.Errorf("unknown subcommand: %s", subcommand) + } + return nil +} diff --git a/functionsystem/apps/cli/internal/debug/yr_debug_test.go b/functionsystem/apps/cli/internal/debug/yr_debug_test.go new file mode 100755 index 0000000000000000000000000000000000000000..0a161e214daeca1aadb3f598729d20e86006998c --- /dev/null +++ b/functionsystem/apps/cli/internal/debug/yr_debug_test.go @@ -0,0 +1,171 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package debug + +import ( + "bytes" + "fmt" + "io" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "cli/constant" +) + +// MockCmdIO 用于模拟 CmdIO 的行为 +type MockCmdIO struct { + mock.Mock +} + +// In 返回模拟的输入 +func (m *MockCmdIO) In() io.Reader { + args := m.Called() + return args.Get(0).(io.Reader) +} + +// Out 返回模拟的输出 +func (m *MockCmdIO) Out() io.Writer { + args := m.Called() + return args.Get(0).(io.Writer) +} + +// ErrOut 返回模拟的错误输出 +func (m *MockCmdIO) ErrOut() io.Writer { + args := m.Called() + return args.Get(0).(io.Writer) +} + +// TestInitYrDebug 测试 InitYrDebug 函数 +func TestInitYrDebug(t *testing.T) { + // 创建一个模拟的 CmdIO + mockCmdIO := &MockCmdIO{} + mockCmdIO.On("In").Return(bytes.NewBufferString("")) + mockCmdIO.On("Out").Return(bytes.NewBuffer(nil)) + mockCmdIO.On("ErrOut").Return(bytes.NewBuffer(nil)) + + // 调用 InitYrDebug 函数 + cmd := InitYrDebug(mockCmdIO) + + // 检查命令是否正确初始化 + assert.NotNil(t, cmd) + assert.Equal(t, "debug", cmd.Use) + assert.Equal(t, fmt.Sprintf("Enter the %s debug shell", constant.PlatformName), cmd.Short) +} + +// TestYrDebug 测试 yrDebug 函数 +func TestYrDebug(t *testing.T) { + // 创建一个模拟的 CmdIO + mockCmdIO := &MockCmdIO{} + mockCmdIO.On("In").Return(bytes.NewBufferString("")) + mockCmdIO.On("Out").Return(bytes.NewBuffer(nil)) + mockCmdIO.On("ErrOut").Return(bytes.NewBuffer(nil)) + + // 设置全局 opts 的 cmdIO + opts.cmdIO = mockCmdIO + + // 调用 yrDebug 函数 + err := yrDebug(&cobra.Command{}, []string{}) + + // 检查是否有错误 + assert.NoError(t, err) +} + +// TestStartDebugShell 测试 startDebugShell 函数 +func TestStartDebugShell(t *testing.T) { + // 创建一个模拟的 CmdIO + mockCmdIO := &MockCmdIO{} + mockCmdIO.On("In").Return(bytes.NewBufferString("quit\n")) + mockCmdIO.On("Out").Return(bytes.NewBuffer(nil)) + mockCmdIO.On("ErrOut").Return(bytes.NewBuffer(nil)) + + // 设置全局 opts 的 cmdIO + opts.cmdIO = mockCmdIO + + // 调用 startDebugShell 函数 + startDebugShell() + + // 检查输出内容 + output := mockCmdIO.Out().(*bytes.Buffer).String() + expectedOutput := "Type 'help' or 'h' for help, 'quit' or 'q' to exit.\n\n" + assert.Contains(t, output, expectedOutput, "startDebugShell output should contain expected output") +} + +// TestHandleHelp 测试 handleHelp 函数 +func TestHandleHelp(t *testing.T) { + // 创建一个模拟的 CmdIO + mockCmdIO := &MockCmdIO{} + mockCmdIO.On("In").Return(bytes.NewBufferString("")) + mockCmdIO.On("Out").Return(bytes.NewBuffer(nil)) + mockCmdIO.On("ErrOut").Return(bytes.NewBuffer(nil)) + + // 调用 handleHelp 函数 + err := handleHelp(mockCmdIO, []string{"help"}) + + // 检查是否有错误 + assert.NoError(t, err) +} + +// TestHandleQuit 测试 handleQuit 函数 +func TestHandleQuit(t *testing.T) { + // 创建一个模拟的 CmdIO + mockCmdIO := &MockCmdIO{} + mockCmdIO.On("In").Return(bytes.NewBufferString("")) + mockCmdIO.On("Out").Return(bytes.NewBuffer(nil)) + mockCmdIO.On("ErrOut").Return(bytes.NewBuffer(nil)) + + // 调用 handleQuit 函数 + err := handleQuit(mockCmdIO, []string{"quit"}) + + // 检查是否有错误 + assert.NoError(t, err) +} + +// TestHandleInstance 测试 handleInstance 函数 +func TestHandleInstance(t *testing.T) { + // 创建一个模拟的 CmdIO + mockCmdIO := &MockCmdIO{} + mockCmdIO.On("In").Return(bytes.NewBufferString("")) + mockCmdIO.On("Out").Return(bytes.NewBuffer(nil)) + mockCmdIO.On("ErrOut").Return(bytes.NewBuffer(nil)) + + // 设置全局 opts 的 cmdIO + opts.cmdIO = mockCmdIO + + // 调用 handleInstance 函数 + err := handleInstance([]string{"instance", "12345"}) + + // 检查是否有错误 + assert.NoError(t, err) +} + +// TestHandleInfo 测试 handleInfo 函数 +func TestHandleInfo(t *testing.T) { + // 创建一个模拟的 CmdIO + mockCmdIO := &MockCmdIO{} + mockCmdIO.On("In").Return(bytes.NewBufferString("")) + mockCmdIO.On("Out").Return(bytes.NewBuffer(nil)) + mockCmdIO.On("ErrOut").Return(bytes.NewBuffer(nil)) + + // 调用 handleInfo 函数 + err := handleInfo(mockCmdIO, []string{"info", "instance"}) + + // 检查是否有错误 + assert.NoError(t, err) +} diff --git a/functionsystem/apps/cli/internal/down/yr_down.go b/functionsystem/apps/cli/internal/down/yr_down.go new file mode 100755 index 0000000000000000000000000000000000000000..df95c74805866841adaf07e1d5795f093ca89c51 --- /dev/null +++ b/functionsystem/apps/cli/internal/down/yr_down.go @@ -0,0 +1,66 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package down provides the down command +package down + +import ( + "fmt" + + "github.com/spf13/cobra" + + "cli/constant" + "cli/pkg/clusterctrl" + "cli/pkg/cmdio" + "cli/utils" +) + +const ( + numDownPositionalArgs = 1 +) + +type options struct { + cmdIO *cmdio.CmdIO + clusterConfig *utils.ClusterConfig +} + +var opts options + +var yrDownCmd = &cobra.Command{ + Use: "down", + Short: fmt.Sprintf("start %s Platform", constant.PlatformName), + Long: fmt.Sprintf(`start %s Platform`, constant.PlatformName), + Example: utils.RawFormat(fmt.Sprintf(` +$ %s up cluster_config.yaml +`, constant.CliName)), + Args: cobra.ExactArgs(numDownPositionalArgs), + RunE: runDown, +} + +// InitCMD init cmd +func InitCMD(cio *cmdio.CmdIO) *cobra.Command { + opts.cmdIO = cio + return yrDownCmd +} + +func runDown(_ *cobra.Command, args []string) error { + clusterConfigFilePath := args[0] + config, err := utils.LoadClusterConfig(clusterConfigFilePath) + if err != nil { + return err + } + return clusterctrl.Down(config, clusterctrl.NewSSHRemoteExecutor(config.SSHCmd)) +} diff --git a/functionsystem/apps/cli/internal/down/yr_down_test.go b/functionsystem/apps/cli/internal/down/yr_down_test.go new file mode 100755 index 0000000000000000000000000000000000000000..4b03494809b958ce8e034059745668a966c11f55 --- /dev/null +++ b/functionsystem/apps/cli/internal/down/yr_down_test.go @@ -0,0 +1,123 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package down + +import ( + "errors" + "testing" + + "github.com/agiledragon/gomonkey/v2" + . "github.com/smartystreets/goconvey/convey" + "github.com/spf13/cobra" + + "cli/pkg/clusterctrl" + "cli/pkg/cmdio" + "cli/utils" +) + +func TestDownCmdMinimal(t *testing.T) { + Convey("Test Down Command (Minimal Coverage)", t, func() { + // 测试 InitCMD + Convey("InitCMD", func() { + Convey("Given a CmdIO instance", func() { + cio := &cmdio.CmdIO{} + + Convey("When InitCMD is called", func() { + cmd := InitCMD(cio) + + Convey("Then it should set opts.cmdIO and return the command", func() { + So(cmd, ShouldNotBeNil) + So(opts.cmdIO, ShouldEqual, cio) + So(cmd.Use, ShouldEqual, "down") // 只验证基本属性 + }) + }) + }) + }) + + // 测试 runDown + Convey("runDown", func() { + Convey("Given a valid config file path and Down succeeds", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + config := &utils.ClusterConfig{} + patches := gomonkey.NewPatches() + patches.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return config, nil + }) + patches.ApplyFunc(clusterctrl.NewSSHRemoteExecutor, func(sshCmd string) clusterctrl.RemoteExecutor { + return nil + }) + patches.ApplyFunc(clusterctrl.Down, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor) error { + return nil + }) + defer patches.Reset() + + Convey("When runDown is called", func() { + err := runDown(cmd, args) + + Convey("Then it should return nil", func() { + So(err, ShouldBeNil) + }) + }) + }) + + Convey("Given a config file path and LoadClusterConfig fails", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + patches := gomonkey.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return nil, errors.New("load config failed") + }) + defer patches.Reset() + + Convey("When runDown is called", func() { + err := runDown(cmd, args) + + Convey("Then it should return the load error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "load config failed") + }) + }) + }) + + Convey("Given a config file path and Down fails", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + config := &utils.ClusterConfig{} + patches := gomonkey.NewPatches() + patches.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return config, nil + }) + patches.ApplyFunc(clusterctrl.NewSSHRemoteExecutor, func(sshCmd string) clusterctrl.RemoteExecutor { + return nil + }) + patches.ApplyFunc(clusterctrl.Down, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor) error { + return errors.New("down failed") + }) + defer patches.Reset() + + Convey("When runDown is called", func() { + err := runDown(cmd, args) + + Convey("Then it should return the down error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "down failed") + }) + }) + }) + }) + }) +} diff --git a/functionsystem/apps/cli/internal/exec/yr_exec.go b/functionsystem/apps/cli/internal/exec/yr_exec.go new file mode 100755 index 0000000000000000000000000000000000000000..f0c368e0534347a66f0f949519b220c8d0df7e6a --- /dev/null +++ b/functionsystem/apps/cli/internal/exec/yr_exec.go @@ -0,0 +1,133 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package yrexec provides the exec command +package exec + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "cli/constant" + "cli/pkg/clusterctrl" + "cli/pkg/cmdio" + "cli/utils" + "cli/utils/colorprint" +) + +const ( + numExecPositionalArgs = 2 + defaultTerminalSize = 80 +) + +type options struct { + cmdIO *cmdio.CmdIO + clusterConfig *utils.ClusterConfig + AlsoStart bool + AlsoStop bool + Env []string + Cmd string +} + +var opts options + +var yrExecCmd = &cobra.Command{ + Use: "exec [flags] -- ", + Short: fmt.Sprintf("exec %s an command on the master node, and start/stop the yuanrong cluster (if need)", + constant.PlatformName), + Long: fmt.Sprintf(`exec %s an command on the master node, and start/stop the yuanrong cluster (if need)`, + constant.PlatformName), + Example: utils.RawFormat(fmt.Sprintf(` +$ %s exec cluster_config.yaml -- /path/to/program +`, constant.CliName)), + Args: cobra.MinimumNArgs(numExecPositionalArgs), + RunE: runExec, +} + +// InitCMD init cmd +func InitCMD(cio *cmdio.CmdIO) *cobra.Command { + opts.cmdIO = cio + yrExecCmd.Flags().BoolVar(&opts.AlsoStart, "start", false, "") + yrExecCmd.Flags().BoolVar(&opts.AlsoStop, "stop", false, "") + yrExecCmd.Flags().StringSliceVarP(&opts.Env, "env", "e", []string{}, "specify environment") + return yrExecCmd +} + +func findOsArgsCommandAfterDoubleSlash() (string, error) { + var cmd string + for i, arg := range os.Args { + if arg == "--" { + cmd = strings.Join(os.Args[i+1:], " ") + break + } + } + if cmd == "" { + return "", errors.New("failed to get command after `--`") + } + return cmd, nil +} + +func getTerminalWidthSplitter(char string) string { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + return strings.Repeat(char, defaultTerminalSize) + } + return strings.Repeat(char, width) +} + +func runExec(_ *cobra.Command, args []string) error { + clusterConfigFilePath := args[0] + clusterConfigCmd, err := findOsArgsCommandAfterDoubleSlash() + if err != nil { + return err + } + + clusterConfig, err := utils.LoadClusterConfig(clusterConfigFilePath) + if err != nil { + return err + } + clusterConfig.Env = append(clusterConfig.Env, opts.Env...) + + executor := clusterctrl.NewSSHRemoteExecutor(clusterConfig.SSHCmd) + + defer func() { + if opts.AlsoStop { + if err := clusterctrl.Down(clusterConfig, executor); err != nil { + colorprint.PrintInteractive(os.Stdout, fmt.Sprintf("failed to stop, err: %s", err.Error())) + } + } + }() + + if opts.AlsoStart { + if err := clusterctrl.Up(clusterConfig, executor); err != nil { + return err + } + } + if wd, err := os.Getwd(); err == nil { + clusterConfigCmd = fmt.Sprintf("cd %s ;", wd) + clusterConfigCmd + } + + colorprint.PrintSuccess(os.Stdout, getTerminalWidthSplitter("=")+"\n", "") + execErr := clusterctrl.Exec(clusterConfig, executor, clusterConfigCmd) + colorprint.PrintSuccess(os.Stdout, getTerminalWidthSplitter("=")+"\n", "") + + return execErr +} diff --git a/functionsystem/apps/cli/internal/exec/yr_exec_test.go b/functionsystem/apps/cli/internal/exec/yr_exec_test.go new file mode 100755 index 0000000000000000000000000000000000000000..848ce4f1cc8f8cfeb712181af9f1606a4cb24e65 --- /dev/null +++ b/functionsystem/apps/cli/internal/exec/yr_exec_test.go @@ -0,0 +1,226 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package exec + +import ( + "errors" + "os" + "testing" + + "github.com/agiledragon/gomonkey/v2" + . "github.com/smartystreets/goconvey/convey" + "github.com/spf13/cobra" + + "cli/pkg/clusterctrl" + "cli/pkg/cmdio" + "cli/utils" +) + +func TestExecCmd(t *testing.T) { + Convey("Test Exec Command", t, func() { + // 测试 InitCMD + Convey("InitCMD", func() { + Convey("Given a CmdIO instance", func() { + cio := &cmdio.CmdIO{} + + Convey("When InitCMD is called", func() { + cmd := InitCMD(cio) + + Convey("Then it should set opts.cmdIO and configure flags", func() { + So(cmd, ShouldNotBeNil) + So(opts.cmdIO, ShouldEqual, cio) + So(cmd.Use, ShouldEqual, "exec [flags] -- ") + So(cmd.Flags().Lookup("start"), ShouldNotBeNil) + So(cmd.Flags().Lookup("stop"), ShouldNotBeNil) + }) + }) + }) + }) + + // 测试 runExec + Convey("runExec", func() { + // 重置 os.Args 以便测试 + origArgs := os.Args + defer func() { os.Args = origArgs }() + + Convey("Given valid args and successful execution", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + os.Args = []string{"test", "exec", "config.yaml", "--", "echo hello"} + config := &utils.ClusterConfig{} + patches := gomonkey.NewPatches() + patches.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return config, nil + }) + patches.ApplyFunc(clusterctrl.NewSSHRemoteExecutor, func(sshCmd string) clusterctrl.RemoteExecutor { + return nil + }) + patches.ApplyFunc(clusterctrl.Up, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor) error { + return nil + }) + patches.ApplyFunc(clusterctrl.Exec, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor, cmd string) error { + return nil + }) + patches.ApplyFunc(clusterctrl.Down, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor) error { + return nil + }) + defer patches.Reset() + + Convey("When runExec is called with default flags", func() { + err := runExec(cmd, args) + + Convey("Then it should return nil", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When runExec is called with --start and --stop", func() { + opts.AlsoStart = true + opts.AlsoStop = true + defer func() { opts.AlsoStart = false; opts.AlsoStop = false }() + err := runExec(cmd, args) + + Convey("Then it should return nil", func() { + So(err, ShouldBeNil) + }) + }) + }) + + Convey("Given args without `--` command", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + os.Args = []string{"test", "exec", "config.yaml"} // 缺少 -- + + Convey("When runExec is called", func() { + err := runExec(cmd, args) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "failed to get command after `--`") + }) + }) + }) + + Convey("Given a config file path and LoadClusterConfig fails", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + os.Args = []string{"test", "exec", "config.yaml", "--", "echo hello"} + patches := gomonkey.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return nil, errors.New("load config failed") + }) + defer patches.Reset() + + Convey("When runExec is called", func() { + err := runExec(cmd, args) + + Convey("Then it should return the load error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "load config failed") + }) + }) + }) + + Convey("Given a failure in Up with --start", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + os.Args = []string{"test", "exec", "config.yaml", "--", "echo hello"} + config := &utils.ClusterConfig{} + opts.AlsoStart = true + defer func() { opts.AlsoStart = false }() + patches := gomonkey.NewPatches() + patches.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return config, nil + }) + patches.ApplyFunc(clusterctrl.NewSSHRemoteExecutor, func(sshCmd string) clusterctrl.RemoteExecutor { + return nil + }) + patches.ApplyFunc(clusterctrl.Up, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor) error { + return errors.New("up failed") + }) + defer patches.Reset() + + Convey("When runExec is called", func() { + err := runExec(cmd, args) + + Convey("Then it should return the up error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "up failed") + }) + }) + }) + + Convey("Given a failure in Exec", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + os.Args = []string{"test", "exec", "config.yaml", "--", "echo hello"} + config := &utils.ClusterConfig{} + patches := gomonkey.NewPatches() + patches.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return config, nil + }) + patches.ApplyFunc(clusterctrl.NewSSHRemoteExecutor, func(sshCmd string) clusterctrl.RemoteExecutor { + return nil + }) + patches.ApplyFunc(clusterctrl.Exec, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor, cmd string) error { + return errors.New("exec failed") + }) + defer patches.Reset() + + Convey("When runExec is called", func() { + err := runExec(cmd, args) + + Convey("Then it should return the exec error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "exec failed") + }) + }) + }) + + Convey("Given a failure in Down with --stop", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + os.Args = []string{"test", "exec", "config.yaml", "--", "echo hello"} + config := &utils.ClusterConfig{} + opts.AlsoStop = true + defer func() { opts.AlsoStop = false }() + patches := gomonkey.NewPatches() + patches.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return config, nil + }) + patches.ApplyFunc(clusterctrl.NewSSHRemoteExecutor, func(sshCmd string) clusterctrl.RemoteExecutor { + return nil + }) + patches.ApplyFunc(clusterctrl.Exec, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor, cmd string) error { + return nil + }) + patches.ApplyFunc(clusterctrl.Down, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor) error { + return errors.New("down failed") + }) + defer patches.Reset() + + Convey("When runExec is called", func() { + err := runExec(cmd, args) + + Convey("Then it should return the down error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "down failed") + }) + }) + }) + }) + }) +} diff --git a/functionsystem/apps/cli/internal/root.go b/functionsystem/apps/cli/internal/root.go index 9319a67c65e98f2f1a14adfbd931957d003eb337..9164e78860776107cc7e61c679a0c0f96ad66b26 100644 --- a/functionsystem/apps/cli/internal/root.go +++ b/functionsystem/apps/cli/internal/root.go @@ -24,9 +24,13 @@ import ( "github.com/spf13/cobra" "cli/constant" + "cli/internal/debug" + "cli/internal/down" + "cli/internal/exec" "cli/internal/start" "cli/internal/status" "cli/internal/stop" + "cli/internal/up" "cli/internal/version" "cli/pkg/cmdio" ) @@ -51,10 +55,14 @@ func NewCmdRoot() *cobra.Command { cobra.OnInitialize() - cmd.AddCommand(start.InitYrCMD(cio)) - cmd.AddCommand(stop.InitYrCMD(cio)) - cmd.AddCommand(status.InitYrCMD(cio)) - cmd.AddCommand(version.InitVersionCMD(cio)) + cmd.AddCommand(start.InitCMD(cio)) + cmd.AddCommand(stop.InitCMD(cio)) + cmd.AddCommand(status.InitCMD(cio)) + cmd.AddCommand(version.InitCMD(cio)) + cmd.AddCommand(exec.InitCMD(cio)) + cmd.AddCommand(up.InitCMD(cio)) + cmd.AddCommand(down.InitCMD(cio)) + cmd.AddCommand(debug.InitCMD(cio)) return cmd } diff --git a/functionsystem/apps/cli/internal/start/yr_start.go b/functionsystem/apps/cli/internal/start/yr_start.go index 5cb84a9910a115d2d2d83c7919c99b1fe19f7b92..19f32019343b72d975b169f2dbf6952f3e2d8f73 100644 --- a/functionsystem/apps/cli/internal/start/yr_start.go +++ b/functionsystem/apps/cli/internal/start/yr_start.go @@ -101,8 +101,8 @@ var yrStartCmd = &cobra.Command{ RunE: yrStartDeploy, } -// InitYrCMD init cmd -func InitYrCMD(cio *cmdio.CmdIO) *cobra.Command { +// InitCMD init cmd +func InitCMD(cio *cmdio.CmdIO) *cobra.Command { yrOpts.cmdIO = cio mem := &runtime.MemStats{} runtime.ReadMemStats(mem) diff --git a/functionsystem/apps/cli/internal/status/yr_status.go b/functionsystem/apps/cli/internal/status/yr_status.go index ef9eceabe3f759e5e6ad26892d493c0edd6facbe..e37ce3eee5632cc3614fc8af07fc54d2f46d2700 100644 --- a/functionsystem/apps/cli/internal/status/yr_status.go +++ b/functionsystem/apps/cli/internal/status/yr_status.go @@ -59,8 +59,8 @@ var yrStatusCmd = &cobra.Command{ RunE: yrStatusYuanRong, } -// InitYrCMD init login cmd -func InitYrCMD(cio *cmdio.CmdIO) *cobra.Command { +// InitCMD init login cmd +func InitCMD(cio *cmdio.CmdIO) *cobra.Command { yrOpts.cmdIO = cio // 添加命令行参数 yrStatusCmd.Flags().StringVar(&yrOpts.etcdEndpoint, "etcd_endpoint", "", "ETCD endpoint address") diff --git a/functionsystem/apps/cli/internal/stop/yr_stop.go b/functionsystem/apps/cli/internal/stop/yr_stop.go index df8834d7ce1dd6753a292ec1576a7496ba80b69e..d2703ef474323aae331da1b5784d1047d3feeccd 100644 --- a/functionsystem/apps/cli/internal/stop/yr_stop.go +++ b/functionsystem/apps/cli/internal/stop/yr_stop.go @@ -62,12 +62,10 @@ const ( defaultGraceExitTimeout = 5 ) -var ( - errorGetZeroYuanRongProcesses = errors.New("didn't find any yuanrong processes") -) +var errorGetZeroYuanRongProcesses = errors.New("didn't find any yuanrong processes") -// InitYrCMD init login cmd -func InitYrCMD(cio *cmdio.CmdIO) *cobra.Command { +// InitCMD init login cmd +func InitCMD(cio *cmdio.CmdIO) *cobra.Command { yrOpts.cmdIO = cio yrStopCmd.Flags().IntVarP(&yrOpts.graceExitTimeout, "grace_exit_timeout", "g", defaultGraceExitTimeout, @@ -88,7 +86,7 @@ func yrStopYuanRong(cmd *cobra.Command, args []string) error { if err != nil { return err } - var keywords = []string{ + keywords := []string{ filepath.Join(yuanRongDir, "deploy", "process", "deploy.sh"), filepath.Join(yuanRongDir, "functionsystem"), filepath.Join(yuanRongDir, "datasystem"), diff --git a/functionsystem/apps/cli/internal/up/yr_up.go b/functionsystem/apps/cli/internal/up/yr_up.go new file mode 100755 index 0000000000000000000000000000000000000000..661bc7e6d2fb507938fb391def91b317fd822f0d --- /dev/null +++ b/functionsystem/apps/cli/internal/up/yr_up.go @@ -0,0 +1,69 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package up provides the up command +package up + +import ( + "fmt" + + "github.com/spf13/cobra" + + "cli/constant" + "cli/pkg/clusterctrl" + "cli/pkg/cmdio" + "cli/utils" +) + +const ( + numUpPositionalArgs = 1 +) + +type options struct { + cmdIO *cmdio.CmdIO + clusterConfig *utils.ClusterConfig + Env []string +} + +var opts options + +var yrUpCmd = &cobra.Command{ + Use: "up", + Short: fmt.Sprintf("up %s on mutiple nodes", constant.PlatformName), + Long: fmt.Sprintf(`up %s on mutiple nodes`, constant.PlatformName), + Example: utils.RawFormat(fmt.Sprintf(` +$ %s up cluster_config.yaml +`, constant.CliName)), + Args: cobra.ExactArgs(numUpPositionalArgs), + RunE: runUp, +} + +// InitCMD init cmd +func InitCMD(cio *cmdio.CmdIO) *cobra.Command { + opts.cmdIO = cio + yrUpCmd.Flags().StringSliceVarP(&opts.Env, "env", "e", []string{}, "specify environment") + return yrUpCmd +} + +func runUp(_ *cobra.Command, args []string) error { + clusterConfigFilePath := args[0] + config, err := utils.LoadClusterConfig(clusterConfigFilePath) + if err != nil { + return err + } + config.Env = append(config.Env, opts.Env...) + return clusterctrl.Up(config, clusterctrl.NewSSHRemoteExecutor(config.SSHCmd)) +} diff --git a/functionsystem/apps/cli/internal/up/yr_up_test.go b/functionsystem/apps/cli/internal/up/yr_up_test.go new file mode 100755 index 0000000000000000000000000000000000000000..3961639d657875191527d190701fb16b0f21697a --- /dev/null +++ b/functionsystem/apps/cli/internal/up/yr_up_test.go @@ -0,0 +1,123 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package up + +import ( + "errors" + "testing" + + "github.com/agiledragon/gomonkey/v2" + . "github.com/smartystreets/goconvey/convey" + "github.com/spf13/cobra" + + "cli/pkg/clusterctrl" + "cli/pkg/cmdio" + "cli/utils" +) + +func TestUpCmd(t *testing.T) { + Convey("Test Up Command", t, func() { + // 测试 InitCMD + Convey("InitCMD", func() { + Convey("Given a CmdIO instance", func() { + cio := &cmdio.CmdIO{} + + Convey("When InitCMD is called", func() { + cmd := InitCMD(cio) + + Convey("Then it should set opts.cmdIO and return the command", func() { + So(cmd, ShouldNotBeNil) + So(opts.cmdIO, ShouldEqual, cio) + So(cmd.Use, ShouldEqual, "up") // 只验证基本属性 + }) + }) + }) + }) + + // 测试 runUp + Convey("runUp", func() { + Convey("Given a valid config file path and Up succeeds", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + config := &utils.ClusterConfig{} + patches := gomonkey.NewPatches() + patches.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return config, nil + }) + patches.ApplyFunc(clusterctrl.NewSSHRemoteExecutor, func(sshCmd string) clusterctrl.RemoteExecutor { + return nil + }) + patches.ApplyFunc(clusterctrl.Up, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor) error { + return nil + }) + defer patches.Reset() + + Convey("When runUp is called", func() { + err := runUp(cmd, args) + + Convey("Then it should return nil", func() { + So(err, ShouldBeNil) + }) + }) + }) + + Convey("Given a config file path and LoadClusterConfig fails", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + patches := gomonkey.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return nil, errors.New("load config failed") + }) + defer patches.Reset() + + Convey("When runUp is called", func() { + err := runUp(cmd, args) + + Convey("Then it should return the load error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "load config failed") + }) + }) + }) + + Convey("Given a config file path and Up fails", func() { + cmd := &cobra.Command{} + args := []string{"config.yaml"} + config := &utils.ClusterConfig{} + patches := gomonkey.NewPatches() + patches.ApplyFunc(utils.LoadClusterConfig, func(filePath string) (*utils.ClusterConfig, error) { + return config, nil + }) + patches.ApplyFunc(clusterctrl.NewSSHRemoteExecutor, func(sshCmd string) clusterctrl.RemoteExecutor { + return nil + }) + patches.ApplyFunc(clusterctrl.Up, func(cfg *utils.ClusterConfig, executor clusterctrl.RemoteExecutor) error { + return errors.New("up failed") + }) + defer patches.Reset() + + Convey("When runUp is called", func() { + err := runUp(cmd, args) + + Convey("Then it should return the up error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "up failed") + }) + }) + }) + }) + }) +} diff --git a/functionsystem/apps/cli/internal/version/version.go b/functionsystem/apps/cli/internal/version/version.go index a1e5ccd8ce9f9ee337034b6347ae806fe821d18f..091cbcb75869a5aeeb6ece34c7ba7616c36edcf2 100644 --- a/functionsystem/apps/cli/internal/version/version.go +++ b/functionsystem/apps/cli/internal/version/version.go @@ -50,8 +50,8 @@ var cmd = &cobra.Command{ }, } -// InitVersionCMD init cmd for version -func InitVersionCMD(cio *cmdio.CmdIO) *cobra.Command { +// InitCMD init cmd for version +func InitCMD(cio *cmdio.CmdIO) *cobra.Command { opts.cmdIO = cio return cmd } diff --git a/functionsystem/apps/cli/pkg/clusterctrl/down.go b/functionsystem/apps/cli/pkg/clusterctrl/down.go new file mode 100755 index 0000000000000000000000000000000000000000..28bd1834062717b67c891285538dab181b59ab44 --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/down.go @@ -0,0 +1,40 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package clusterctrl + +import ( + "os" + + "cli/utils" + "cli/utils/colorprint" +) + +// Down the cluster +func Down(cfg *utils.ClusterConfig, executor RemoteExecutor) error { + shutdownCommands := utils.ReplaceClusterConfigPlaceholderInAllCommands(cfg.StopCmd, + utils.PlaceholderClusterName, cfg.ClusterName) + _, err := executor.RunCommandsOnHostsParallelSync(cfg.AgentIp, shutdownCommands, cfg.Env) + if err != nil { + return err + } + colorprint.PrintSuccess(os.Stdout, "succeed ", "to stop all agents") + if _, err := executor.RunCommandsSync(cfg.MasterIp[0], shutdownCommands, cfg.Env); err != nil { + return err + } + colorprint.PrintSuccess(os.Stdout, "succeed ", "to stop master") + return nil +} diff --git a/functionsystem/apps/cli/pkg/clusterctrl/down_test.go b/functionsystem/apps/cli/pkg/clusterctrl/down_test.go new file mode 100755 index 0000000000000000000000000000000000000000..cb8c0e30e69fe991e458ac2dc2c6c22e1daa2060 --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/down_test.go @@ -0,0 +1,140 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package clusterctrl + +import ( + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/agiledragon/gomonkey/v2" + . "github.com/smartystreets/goconvey/convey" + + "cli/utils" +) + +func TestExecAndDown(t *testing.T) { + // 测试 Down 函数 + Convey("Down", t, func() { + Convey("Given a valid config and successful shutdown", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master-ip"}, + AgentIp: []string{"agent1-ip", "agent2-ip"}, + StopCmd: []string{"stop " + utils.PlaceholderClusterName}, + ClusterName: "test-cluster", + } + mock := &mockExecutor{} + patches := gomonkey.NewPatches() + patches.ApplyMethod(mock, "RunCommandsOnHostsParallelSync", func(_ *mockExecutor, hosts []string, cmds []string) ([]result, error) { + if len(hosts) == 2 && hosts[0] == "agent1-ip" && hosts[1] == "agent2-ip" && cmds[0] == "stop test-cluster" { + return []result{{Host: "agent1-ip"}, {Host: "agent2-ip"}}, nil + } + return nil, errors.New("unexpected agent hosts or commands") + }) + patches.ApplyMethod(mock, "RunCommandsSync", func(_ *mockExecutor, host string, cmds []string) ([]byte, error) { + if host == "master-ip" && cmds[0] == "stop test-cluster" { + return nil, nil + } + return nil, errors.New("unexpected master host or command") + }) + defer patches.Reset() + + // 重定向 stdout 以捕获输出 + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + defer func() { + err := w.Close() + if err != nil { + return + } + os.Stdout = origStdout + }() + + Convey("When Down is called", func() { + err := Down(cfg, mock) + + Convey("Then it should stop the cluster successfully", func() { + So(err, ShouldBeNil) + + // 读取捕获的输出 + err := w.Close() + if err != nil { + return + } + var buf strings.Builder + _, _ = io.Copy(&buf, r) + output := buf.String() + So(output, ShouldContainSubstring, "succeed to stop all agents") + So(output, ShouldContainSubstring, "succeed to stop master") + }) + }) + }) + + Convey("Given a failure in stopping agents", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master-ip"}, + AgentIp: []string{"agent1-ip"}, + StopCmd: []string{"stop " + utils.PlaceholderClusterName}, + ClusterName: "test-cluster", + } + mock := &mockExecutor{} + patches := gomonkey.ApplyMethod(mock, "RunCommandsOnHostsParallelSync", func(_ *mockExecutor, hosts []string, cmds []string) ([]result, error) { + return nil, errors.New("agent shutdown failed") + }) + defer patches.Reset() + + Convey("When Down is called", func() { + err := Down(cfg, mock) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "agent shutdown failed") + }) + }) + }) + + Convey("Given a failure in stopping master", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master-ip"}, + AgentIp: []string{"agent1-ip"}, + StopCmd: []string{"stop " + utils.PlaceholderClusterName}, + ClusterName: "test-cluster", + } + mock := &mockExecutor{} + patches := gomonkey.NewPatches() + patches.ApplyMethod(mock, "RunCommandsOnHostsParallelSync", func(_ *mockExecutor, hosts []string, cmds []string) ([]result, error) { + return []result{{Host: "agent1-ip"}}, nil + }) + patches.ApplyMethod(mock, "RunCommandsSync", func(_ *mockExecutor, host string, cmds []string) ([]byte, error) { + return nil, errors.New("master shutdown failed") + }) + defer patches.Reset() + + Convey("When Down is called", func() { + err := Down(cfg, mock) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "master shutdown failed") + }) + }) + }) + }) +} diff --git a/functionsystem/apps/cli/pkg/clusterctrl/exec.go b/functionsystem/apps/cli/pkg/clusterctrl/exec.go new file mode 100755 index 0000000000000000000000000000000000000000..46e61fd415b871848b39e4f4abc0632f53fe20c1 --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/exec.go @@ -0,0 +1,53 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package clusterctrl + +import ( + "errors" + "fmt" + + "cli/utils" +) + +// Exec a command on master IP +func Exec(cfg *utils.ClusterConfig, executor RemoteExecutor, execCommand string) error { + if len(cfg.MasterIp) != 1 { + return errors.New("only accept one master ip") + } + stream, execErr := executor.RunCommandStream(cfg.MasterIp[0], execCommand, cfg.Env) + if execErr != nil { + return execErr + } + if stream == nil { + return errors.New("unexpected nil pointer of the output stream when exec command") + } + finished := false + for finished == false { + select { + case r, ok := <-stream: + finished = !ok + if finished { + return nil + } + if r.Error != nil { + return r.Error + } + fmt.Printf("%s", r.Output) + } + } + return nil +} diff --git a/functionsystem/apps/cli/pkg/clusterctrl/exec_test.go b/functionsystem/apps/cli/pkg/clusterctrl/exec_test.go new file mode 100755 index 0000000000000000000000000000000000000000..f3dae9bd256425dcd507b53ffc936127798ca46d --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/exec_test.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package clusterctrl + +import ( + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/agiledragon/gomonkey/v2" + . "github.com/smartystreets/goconvey/convey" + + "cli/utils" +) + +func TestExec(t *testing.T) { + // 测试 Exec 函数 + Convey("Exec", t, func() { + Convey("Given a valid config and successful command stream", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master-ip"}, + } + execCommand := "echo hello" + mock := &mockExecutor{} + output := []string{"hello\n", "world\n"} + streamChan := make(chan result, 2) + for _, out := range output { + streamChan <- result{Output: []byte(out)} + } + close(streamChan) + patches := gomonkey.ApplyMethod(mock, "RunCommandStream", func(_ *mockExecutor, host, cmd string) (chan result, error) { + if host == "master-ip" && cmd == execCommand { + return streamChan, nil + } + return nil, errors.New("unexpected host or command") + }) + defer patches.Reset() + + // 重定向 stdout 以捕获输出 + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + defer func() { + err := w.Close() + if err != nil { + return + } + os.Stdout = origStdout + }() + + Convey("When Exec is called", func() { + err := Exec(cfg, mock, execCommand) + + Convey("Then it should stream the output without error", func() { + So(err, ShouldBeNil) + + err := w.Close() + if err != nil { + return + } + var buf strings.Builder + _, _ = io.Copy(&buf, r) + outputStr := buf.String() + So(outputStr, ShouldEqual, "hello\nworld\n.") + }) + }) + }) + + Convey("Given a config with multiple master IPs", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master1-ip", "master2-ip"}, + } + mock := &mockExecutor{} + + Convey("When Exec is called", func() { + err := Exec(cfg, mock, "echo hello") + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "only accept one master ip") + }) + }) + }) + + Convey("Given a failure in command stream", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master-ip"}, + } + execCommand := "fail command" + mock := &mockExecutor{} + patches := gomonkey.ApplyMethod(mock, "RunCommandStream", func(_ *mockExecutor, host, cmd string) (chan result, error) { + return nil, errors.New("stream failed") + }) + defer patches.Reset() + + Convey("When Exec is called", func() { + err := Exec(cfg, mock, execCommand) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "stream failed") + }) + }) + }) + }) +} diff --git a/functionsystem/apps/cli/pkg/clusterctrl/remote_executor.go b/functionsystem/apps/cli/pkg/clusterctrl/remote_executor.go new file mode 100755 index 0000000000000000000000000000000000000000..b4b25c736655ba769822e12931c0d5219e3351ce --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/remote_executor.go @@ -0,0 +1,148 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package clusterctrl provides the clusterctrl commands, includes Up, Down, Exec +// and some RemoteExecutor (SSHRemoteExecutor) +package clusterctrl + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" + "sync" +) + +// RemoteExecutor interface +type RemoteExecutor interface { + // RunCommandWithPipe implement a basic method + RunCommandWithPipe(_ string, _ string, _ []string) (io.ReadCloser, func() error, error) + // RunCommandSync run a single command on a remote host + RunCommandSync(host string, command string, _ []string) ([]byte, error) + // RunCommandsSync - execute multiple commands at once + RunCommandsSync(host string, commands []string, _ []string) ([]byte, error) + // RunCommandsOnHostsParallelSync - execute multiple commands at multiple hosts sync + RunCommandsOnHostsParallelSync(hosts []string, commands []string, _ []string) ([]result, error) + // RunCommandStream - the result would be like a chunk + RunCommandStream(host string, command string, _ []string) (chan result, error) +} + +type result struct { + Host string + Output []byte + Error error +} + +type baseRemoteExecutor struct { + // Impl is the real implement of the RunCommandWithPipe method + Impl RemoteExecutor +} + +// RunCommandWithPipe - basic method, should be implemented by real implementation +// all other methods will be implemented with this basic method +func (b *baseRemoteExecutor) RunCommandWithPipe(_ string, _ string, _ []string) (io.ReadCloser, func() error, error) { + return nil, nil, errors.New("the RunCommandWithPipe is NOT IMPLEMENTED") +} + +// RunCommandSync run a single command on a remote host +func (b *baseRemoteExecutor) RunCommandSync(host string, command string, env []string) ([]byte, error) { + outputPipe, waitFn, err := b.Impl.RunCommandWithPipe(host, command, env) + if err != nil { + return nil, err + } + defer func(outputPipe io.ReadCloser) { + if outputPipe.Close() != nil { + return + } + }(outputPipe) + + var output bytes.Buffer + _, err = io.Copy(&output, outputPipe) + if err != nil { + return nil, fmt.Errorf("failed to read command output: %w", err) + } + if err := waitFn(); err != nil { + return output.Bytes(), fmt.Errorf("command failed: %w, output: %s", err, output.Bytes()) + } + return output.Bytes(), nil +} + +// RunCommandsSync - execute multiple commands at once +func (b *baseRemoteExecutor) RunCommandsSync(host string, commands []string, env []string) ([]byte, error) { + script := strings.Join(commands, " && ") + return b.Impl.RunCommandSync(host, script, env) +} + +// RunCommandsOnHostsParallelSync - execute multiple commands at multiple hosts sync +func (b *baseRemoteExecutor) RunCommandsOnHostsParallelSync(hosts []string, commands []string, env []string) ([]result, error) { + var wg sync.WaitGroup + var errs []error + results := make([]result, len(hosts)) + var mu sync.Mutex // protect results and errs + + wg.Add(len(hosts)) + for i, host := range hosts { + go func(i int, host string) { + defer wg.Done() + output, err := b.Impl.RunCommandsSync(host, commands, env) + results[i] = result{Host: host, Output: output, Error: err} + if err != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("host %s, command %s: %w", host, strings.Join(commands, ","), err)) + mu.Unlock() + } + }(i, host) + } + wg.Wait() + + if len(errs) > 0 { + return results, errors.Join(errs...) + } + return results, nil +} + +// RunCommandStream execute a method and redirect the output as a stream +func (b *baseRemoteExecutor) RunCommandStream(host string, command string, env []string) (chan result, error) { + pipe, waitFn, err := b.Impl.RunCommandWithPipe(host, command, env) + if err != nil { + return nil, err + } + + outCh := make(chan result, 100) + go func() { + defer close(outCh) + defer func(pipe io.ReadCloser) { + if pipe.Close() != nil { + } + }(pipe) + + buf := make([]byte, 1024) + for { + n, err := pipe.Read(buf) + if n > 0 { + outCh <- result{Output: append([]byte(nil), buf[:n]...)} + } + if err != nil { + waitErr := waitFn() + outCh <- result{Error: waitErr} + return + } + } + }() + + return outCh, nil +} diff --git a/functionsystem/apps/cli/pkg/clusterctrl/remote_executor_test.go b/functionsystem/apps/cli/pkg/clusterctrl/remote_executor_test.go new file mode 100755 index 0000000000000000000000000000000000000000..adfc331af26c9419a49c4544399516b65858da45 --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/remote_executor_test.go @@ -0,0 +1,279 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package clusterctrl + +import ( + "errors" + "fmt" + "io" + "strings" + "sync" + "testing" + + "github.com/agiledragon/gomonkey/v2" + . "github.com/smartystreets/goconvey/convey" +) + +// mockExecutor 是 RemoteExecutor 的 mock 实现 +type mockExecutor struct { + runPipeOutput string + runPipeError error + waitFnError error + mutex sync.Mutex + closed bool +} + +func newMockExecutor(output string, runErr, waitErr error) *mockExecutor { + return &mockExecutor{ + runPipeOutput: output, + runPipeError: runErr, + waitFnError: waitErr, + } +} + +func (m *mockExecutor) RunCommandWithPipe(_, _ string, _ []string) (io.ReadCloser, func() error, error) { + if m.runPipeError != nil { + return nil, nil, m.runPipeError + } + reader := strings.NewReader(m.runPipeOutput) + rc := &mockReadCloser{Reader: reader, closer: m} + waitFn := func() error { + return m.waitFnError + } + return rc, waitFn, nil +} + +func (m *mockExecutor) RunCommandSync(_, _ string, _ []string) ([]byte, error) { + return nil, errors.New("not implemented") +} + +func (m *mockExecutor) RunCommandsSync(_ string, _ []string, _ []string) ([]byte, error) { + return nil, errors.New("not implemented") +} + +func (m *mockExecutor) RunCommandsOnHostsParallelSync(_ []string, _ []string, _ []string) ([]result, error) { + return nil, errors.New("not implemented") +} + +func (m *mockExecutor) RunCommandStream(_, _ string, _ []string) (chan result, error) { + return nil, errors.New("not implemented") +} + +type mockReadCloser struct { + io.Reader + closer *mockExecutor +} + +func (m *mockReadCloser) Close() error { + m.closer.mutex.Lock() + defer m.closer.mutex.Unlock() + if !m.closer.closed { + m.closer.closed = true + } + return nil +} + +// 测试文件 +func TestBaseRemoteExecutor(t *testing.T) { + Convey("Test baseRemoteExecutor", t, func() { + // 测试 RunCommandSync + Convey("RunCommandSync", func() { + Convey("Given a successful command execution", func() { + mock := newMockExecutor("hello\nworld\n", nil, nil) + b := &baseRemoteExecutor{Impl: mock} + + Convey("When RunCommandSync is called", func() { + output, err := b.RunCommandSync("test-host", "echo hello", []string{}) + + Convey("Then it should return the output without error", func() { + So(err, ShouldBeNil) + So(string(output), ShouldEqual, "hello\nworld\n") + }) + }) + }) + + Convey("Given a command execution with wait error", func() { + mock := newMockExecutor("partial output", nil, errors.New("command failed")) + b := &baseRemoteExecutor{Impl: mock} + + Convey("When RunCommandSync is called", func() { + output, err := b.RunCommandSync("test-host", "fail", []string{}) + + Convey("Then it should return partial output with error", func() { + So(err, ShouldNotBeNil) + So(string(output), ShouldEqual, "partial output") + So(err.Error(), ShouldContainSubstring, "command failed") + }) + }) + }) + + Convey("Given a command execution with initial error", func() { + mock := newMockExecutor("", errors.New("connection failed"), nil) + b := &baseRemoteExecutor{Impl: mock} + + Convey("When RunCommandSync is called", func() { + output, err := b.RunCommandSync("test-host", "test", []string{}) + + Convey("Then it should return error without output", func() { + So(err, ShouldResemble, errors.New("connection failed")) + So(output, ShouldBeNil) + }) + }) + }) + }) + + // 测试 RunCommandsSync + Convey("RunCommandsSync", func() { + Convey("Given multiple successful commands", func() { + mock := newMockExecutor("hello\nworld\n", nil, nil) + b := &baseRemoteExecutor{Impl: mock} + patches := gomonkey.ApplyMethodFunc(mock, "RunCommandSync", + func(host, cmd string) ([]byte, error) { + return []byte("hello\nworld\n"), nil + }) + defer patches.Reset() + + Convey("When RunCommandsSync is called", func() { + output, err := b.RunCommandsSync("test-host", []string{"echo hello", "echo world"}, []string{}) + + Convey("Then it should return concatenated output", func() { + So(err, ShouldBeNil) + So(string(output), ShouldEqual, "hello\nworld\n") + }) + }) + }) + + Convey("Given multiple commands with error", func() { + mock := newMockExecutor("", nil, nil) + b := &baseRemoteExecutor{Impl: mock} + patches := gomonkey.ApplyMethodFunc(mock, "RunCommandSync", + func(host, cmd string) ([]byte, error) { + return nil, errors.New("execution failed") + }) + defer patches.Reset() + + Convey("When RunCommandsSync is called", func() { + output, err := b.RunCommandsSync("test-host", []string{"fail"}, []string{}) + + Convey("Then it should return error", func() { + So(err, ShouldNotBeNil) + So(output, ShouldBeNil) + }) + }) + }) + }) + + // 测试 RunCommandsOnHostsParallelSync + Convey("RunCommandsOnHostsParallelSync", func() { + Convey("Given multiple hosts with successful commands", func() { + mock := newMockExecutor("", nil, nil) + b := &baseRemoteExecutor{Impl: mock} + patches := gomonkey.ApplyMethodFunc(mock, "RunCommandsSync", + func(host string, _ []string) ([]byte, error) { + return []byte(fmt.Sprintf("test from %s", host)), nil + }) + defer patches.Reset() + + Convey("When RunCommandsOnHostsParallelSync is called", func() { + results, err := b.RunCommandsOnHostsParallelSync([]string{"host1", "host2"}, []string{"echo test"}, []string{}) + + Convey("Then it should return results for all hosts", func() { + So(err, ShouldBeNil) + So(len(results), ShouldEqual, 2) + So(string(results[0].Output), ShouldEqual, "test from host1") + So(string(results[1].Output), ShouldEqual, "test from host2") + So(results[0].Error, ShouldBeNil) + So(results[1].Error, ShouldBeNil) + }) + }) + }) + + Convey("Given multiple hosts with one failure", func() { + mock := newMockExecutor("", nil, nil) + b := &baseRemoteExecutor{Impl: mock} + patches := gomonkey.ApplyMethodFunc(mock, "RunCommandsSync", + func(host string, _ []string) ([]byte, error) { + if host == "host2" { + return nil, errors.New("failed on host2") + } + return []byte("test from host1"), nil + }) + defer patches.Reset() + + Convey("When RunCommandsOnHostsParallelSync is called", func() { + results, err := b.RunCommandsOnHostsParallelSync([]string{"host1", "host2"}, []string{"echo test"}, []string{}) + + Convey("Then it should return partial results with error", func() { + So(err, ShouldNotBeNil) + So(len(results), ShouldEqual, 2) + So(string(results[0].Output), ShouldEqual, "test from host1") + So(results[0].Error, ShouldBeNil) + So(results[1].Error, ShouldNotBeNil) + }) + }) + }) + }) + + // 测试 RunCommandStream + Convey("RunCommandStream", func() { + Convey("Given a streaming command", func() { + mock := newMockExecutor("chunk1\nchunk2\n", nil, nil) + b := &baseRemoteExecutor{Impl: mock} + + Convey("When RunCommandStream is called", func() { + ch, err := b.RunCommandStream("test-host", "stream data", []string{}) + + Convey("Then it should stream the output in chunks", func() { + So(err, ShouldBeNil) + var outputs []string + for r := range ch { + So(r.Error, ShouldBeNil) + outputs = append(outputs, string(r.Output)) + } + So(len(outputs), ShouldBeGreaterThan, 0) + So(strings.Join(outputs, ""), ShouldEqual, "chunk1\nchunk2\n") + }) + }) + }) + + Convey("Given a streaming command with error", func() { + mock := newMockExecutor("partial", nil, errors.New("stream failed")) + b := &baseRemoteExecutor{Impl: mock} + + Convey("When RunCommandStream is called", func() { + ch, err := b.RunCommandStream("test-host", "fail stream", []string{}) + + Convey("Then it should return partial output and an error", func() { + So(err, ShouldBeNil) + var outputs []string + var lastErr error + for r := range ch { + if r.Error != nil { + lastErr = r.Error + } else { + outputs = append(outputs, string(r.Output)) + } + } + So(len(outputs), ShouldEqual, 1) + So(outputs[0], ShouldEqual, "partial") + So(lastErr, ShouldResemble, errors.New("stream failed")) + }) + }) + }) + }) + }) +} diff --git a/functionsystem/apps/cli/pkg/clusterctrl/ssh_executor.go b/functionsystem/apps/cli/pkg/clusterctrl/ssh_executor.go new file mode 100755 index 0000000000000000000000000000000000000000..b794f667deb511c95680809d5d0c603374b12d2f --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/ssh_executor.go @@ -0,0 +1,110 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package clusterctrl + +import ( + "fmt" + "io" + "net" + "os/exec" + "strings" + + "cli/utils" +) + +func isLocalIP(ip string) bool { + targetIP := net.ParseIP(ip) + if targetIP == nil { + return false + } + + if targetIP.IsLoopback() || targetIP.IsLinkLocalUnicast() || targetIP.IsLinkLocalMulticast() { + return true + } + + addrs, err := net.InterfaceAddrs() + if err != nil { + return false + } + + for _, addr := range addrs { + ipNet, _, err := net.ParseCIDR(addr.String()) + if err != nil { + continue + } + if targetIP.Equal(ipNet) { + return true + } + } + return false +} + +// SSHRemoteExecutor can execute commands on remote hosts with ssh +type SSHRemoteExecutor struct { + baseRemoteExecutor + sshCmdTemplate string // the ssh command +} + +// NewSSHRemoteExecutor - +func NewSSHRemoteExecutor(sshCmdTemplate string) RemoteExecutor { + se := &SSHRemoteExecutor{ + sshCmdTemplate: sshCmdTemplate, + } + se.Impl = se + return se +} + +// prepareCommand makes the ssh command +func (se *SSHRemoteExecutor) prepareCommand(host, command string) *exec.Cmd { + result := se.sshCmdTemplate + result = strings.ReplaceAll(result, utils.PlaceholderTarget, host) + result = strings.ReplaceAll(result, utils.PlaceholderCommand, command) + remoteCommand := strings.Fields(result) + return exec.Command(remoteCommand[0], remoteCommand[1:]...) +} + +// RunCommandWithPipe - +func (se *SSHRemoteExecutor) RunCommandWithPipe(host string, command string, + env []string, +) (io.ReadCloser, func() error, error) { + commandWithEnv := "" + for _, e := range env { + commandWithEnv += " export " + e + " && " + } + commandWithEnv += command + remoteCommand := se.prepareCommand(host, commandWithEnv) + if isLocalIP(host) { + remoteCommand = exec.Command("/bin/sh", "-c", command) + } + + remoteCommand.Env = append(remoteCommand.Env, env...) + outPipe, err := remoteCommand.StdoutPipe() + if err != nil { + return nil, nil, fmt.Errorf("SSH remote executor failed to get stdout pipe: %v", err) + } + remoteCommand.Stderr = remoteCommand.Stdout + if err := remoteCommand.Start(); err != nil { + if closeErr := outPipe.Close(); closeErr != nil { + // pass + } + return nil, nil, fmt.Errorf("SSH remote executor failed to start command on %s: %v", host, err) + } + waitFn := func() error { + return remoteCommand.Wait() + } + return outPipe, waitFn, nil +} diff --git a/functionsystem/apps/cli/pkg/clusterctrl/ssh_executor_test.go b/functionsystem/apps/cli/pkg/clusterctrl/ssh_executor_test.go new file mode 100755 index 0000000000000000000000000000000000000000..483f02e7a5ae2bee930d21666e7795772fe5184b --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/ssh_executor_test.go @@ -0,0 +1,210 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package clusterctrl + +import ( + "errors" + "io" + "os/exec" + "strings" + "testing" + + "cli/utils" + + "github.com/agiledragon/gomonkey/v2" + . "github.com/smartystreets/goconvey/convey" +) + +func TestSSHRemoteExecutor(t *testing.T) { + // mockExecutor 定义假定已在 remote_executor_test.go 中,这里创建临时实例 + dummyMockExecutor := &mockExecutor{} // 用于 mockReadCloser 的 closer 字段 + + Convey("Test SSHRemoteExecutor", t, func() { + // 测试 prepareCommand + Convey("prepareCommand", func() { + Convey("Given a valid SSH command template", func() { + template := "ssh -o ConnectTimeout=5 " + utils.PlaceholderTarget + " " + utils.PlaceholderCommand + se := &SSHRemoteExecutor{sshCmdTemplate: template} + + Convey("When prepareCommand is called with host and command", func() { + cmd := se.prepareCommand("test-host", "echo hello") + + Convey("Then it should construct the correct command", func() { + So(len(cmd.Args), ShouldEqual, 6) + So(cmd.Args[0], ShouldEqual, "ssh") + So(cmd.Args[1], ShouldEqual, "-o") + So(cmd.Args[2], ShouldEqual, "ConnectTimeout=5") + So(cmd.Args[3], ShouldEqual, "test-host") + So(cmd.Args[4], ShouldEqual, "echo") + So(cmd.Args[5], ShouldEqual, "hello") + }) + }) + }) + }) + + // 测试 RunCommandWithPipe + Convey("RunCommandWithPipe", func() { + Convey("Given a successful SSH command execution", func() { + template := "ssh " + utils.PlaceholderTarget + " " + utils.PlaceholderCommand + se := NewSSHRemoteExecutor(template).(*SSHRemoteExecutor) + output := "hello from remote\n" + mockPipe := &mockReadCloser{Reader: strings.NewReader(output), closer: dummyMockExecutor} + + // 创建真实的 *exec.Cmd + cmd := exec.Command("ssh", "test-host", "echo hello") + patches := gomonkey.NewPatches() + patches.ApplyMethod(cmd, "StdoutPipe", func(*exec.Cmd) (io.ReadCloser, error) { + return mockPipe, nil + }) + patches.ApplyMethod(cmd, "Start", func(*exec.Cmd) error { + return nil + }) + patches.ApplyMethod(cmd, "Wait", func(*exec.Cmd) error { + return nil + }) + patches.ApplyFunc(exec.Command, func(name string, args ...string) *exec.Cmd { + return cmd + }) + defer patches.Reset() + + Convey("When RunCommandWithPipe is called", func() { + reader, waitFn, err := se.RunCommandWithPipe("test-host", "echo hello", []string{}) + + Convey("Then it should return a valid reader and wait function without error", func() { + So(err, ShouldBeNil) + So(reader, ShouldNotBeNil) + So(waitFn, ShouldNotBeNil) + + // 读取输出 + buf := make([]byte, 1024) + n, readErr := reader.Read(buf) + So(readErr, ShouldBeNil) + So(string(buf[:n]), ShouldEqual, "hello from remote\n") + + // 测试 waitFn + waitErr := waitFn() + So(waitErr, ShouldBeNil) + + // 关闭 reader + closeErr := reader.Close() + So(closeErr, ShouldBeNil) + }) + }) + }) + + Convey("Given an SSH command with stdout pipe error", func() { + template := "ssh " + utils.PlaceholderTarget + " " + utils.PlaceholderCommand + se := NewSSHRemoteExecutor(template).(*SSHRemoteExecutor) + + cmd := exec.Command("ssh", "test-host", "echo hello") + patches := gomonkey.NewPatches() + patches.ApplyMethod(cmd, "StdoutPipe", func(*exec.Cmd) (io.ReadCloser, error) { + return nil, errors.New("stdout pipe not available") + }) + patches.ApplyFunc(exec.Command, func(name string, args ...string) *exec.Cmd { + return cmd + }) + defer patches.Reset() + + Convey("When RunCommandWithPipe is called", func() { + reader, waitFn, err := se.RunCommandWithPipe("test-host", "echo hello", []string{}) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "failed to get stdout pipe") + So(reader, ShouldBeNil) + So(waitFn, ShouldBeNil) + }) + }) + }) + + Convey("Given an SSH command that fails to start", func() { + template := "ssh " + utils.PlaceholderTarget + " " + utils.PlaceholderCommand + se := NewSSHRemoteExecutor(template).(*SSHRemoteExecutor) + mockPipe := &mockReadCloser{Reader: strings.NewReader(""), closer: dummyMockExecutor} + + cmd := exec.Command("ssh", "test-host", "echo hello") + patches := gomonkey.NewPatches() + patches.ApplyMethod(cmd, "StdoutPipe", func(*exec.Cmd) (io.ReadCloser, error) { + return mockPipe, nil + }) + patches.ApplyMethod(cmd, "Start", func(*exec.Cmd) error { + return errors.New("ssh connection failed") + }) + patches.ApplyFunc(exec.Command, func(name string, args ...string) *exec.Cmd { + return cmd + }) + defer patches.Reset() + + Convey("When RunCommandWithPipe is called", func() { + reader, waitFn, err := se.RunCommandWithPipe("test-host", "echo hello", []string{}) + + Convey("Then it should return an error and close the pipe", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "failed to start command") + So(reader, ShouldBeNil) + So(waitFn, ShouldBeNil) + So(mockPipe.closer.closed, ShouldBeTrue) + }) + }) + }) + + Convey("Given an SSH command that fails on wait", func() { + template := "ssh " + utils.PlaceholderTarget + " " + utils.PlaceholderCommand + se := NewSSHRemoteExecutor(template).(*SSHRemoteExecutor) + output := "partial output\n" + mockPipe := &mockReadCloser{Reader: strings.NewReader(output), closer: dummyMockExecutor} + + cmd := exec.Command("ssh", "test-host", "fail command") + patches := gomonkey.NewPatches() + patches.ApplyMethod(cmd, "StdoutPipe", func(*exec.Cmd) (io.ReadCloser, error) { + return mockPipe, nil + }) + patches.ApplyMethod(cmd, "Start", func(*exec.Cmd) error { + return nil + }) + patches.ApplyMethod(cmd, "Wait", func(*exec.Cmd) error { + return errors.New("command exited with error") + }) + patches.ApplyFunc(exec.Command, func(name string, args ...string) *exec.Cmd { + return cmd + }) + defer patches.Reset() + + Convey("When RunCommandWithPipe is called", func() { + reader, waitFn, err := se.RunCommandWithPipe("test-host", "fail command", []string{}) + + Convey("Then it should return a reader and a wait function with error", func() { + So(err, ShouldBeNil) + So(reader, ShouldNotBeNil) + So(waitFn, ShouldNotBeNil) + + // 读取输出 + buf := make([]byte, 1024) + n, readErr := reader.Read(buf) + So(readErr, ShouldBeNil) + So(string(buf[:n]), ShouldEqual, "partial output\n") + + // 测试 waitFn + waitErr := waitFn() + So(waitErr, ShouldResemble, errors.New("command exited with error")) + }) + }) + }) + }) + }) +} diff --git a/functionsystem/apps/cli/pkg/clusterctrl/up.go b/functionsystem/apps/cli/pkg/clusterctrl/up.go new file mode 100755 index 0000000000000000000000000000000000000000..23dadcda4adc3d0a9a4f6ca00b5ac7301f19d268 --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/up.go @@ -0,0 +1,82 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package clusterctrl + +import ( + "errors" + "fmt" + "os" + "strings" + + "cli/constant" + "cli/utils" + "cli/utils/colorprint" +) + +// Up a cluster +func Up(cfg *utils.ClusterConfig, executor RemoteExecutor) error { + masterInfo, err := upMasterNode(cfg, executor) + if err != nil { + return err + } + colorprint.PrintSuccess(os.Stdout, "succeed ", fmt.Sprintf("to start up master node, master info: %s", masterInfo)) + cfg.Env = append(cfg.Env, fmt.Sprintf("YR_MASTER_INFO=%s", masterInfo)) + err = upAgentNode(cfg, executor, masterInfo) + if err != nil { + return err + } + colorprint.PrintSuccess(os.Stdout, "succeed ", fmt.Sprintf("to start up %d agent nodes.", len(cfg.AgentIp))) + colorprint.PrintSuccess(os.Stdout, "succeed ", fmt.Sprintf("to up a yuanrong cluster.")) + return nil +} + +func upMasterNode(cfg *utils.ClusterConfig, executor RemoteExecutor) (string, error) { + if len(cfg.MasterIp) != 1 { + return "", errors.New("only accept one master ip") + } + masterIP := cfg.MasterIp[0] + + out, err := executor.RunCommandsSync(masterIP, cfg.MasterCmd, cfg.Env) + if err != nil { + return "", err + } + masterInfo := "" + splitOut := strings.Split(string(out), "\n") + for i, v := range splitOut { + if strings.Contains(v, "Cluster master info:") && i+1 < len(splitOut) { + masterInfo = splitOut[i+1] + masterInfo = strings.Trim(masterInfo, "\n") + masterInfo = strings.Trim(masterInfo, "\t") + masterInfo = strings.Trim(masterInfo, " ") + return masterInfo, nil + } + } + + masterInfoByte, err := executor.RunCommandSync(masterIP, fmt.Sprintf("cat %s", + constant.DefaultYuanRongCurrentMasterInfoPath), cfg.Env) + if err != nil { + return "", fmt.Errorf("failed to get master info: %w", err) + } + return strings.Trim(string(masterInfoByte), "\n"), nil +} + +func upAgentNode(cfg *utils.ClusterConfig, executor RemoteExecutor, masterInfo string) error { + agentCommands := utils.ReplaceClusterConfigPlaceholderInAllCommands(cfg.AgentCmd, utils.PlaceholderMasterInfo, + masterInfo) + _, err := executor.RunCommandsOnHostsParallelSync(cfg.AgentIp, agentCommands, cfg.Env) + return err +} diff --git a/functionsystem/apps/cli/pkg/clusterctrl/up_test.go b/functionsystem/apps/cli/pkg/clusterctrl/up_test.go new file mode 100755 index 0000000000000000000000000000000000000000..1394bbcaaebfcc336b6e2b65a2af946b698801a6 --- /dev/null +++ b/functionsystem/apps/cli/pkg/clusterctrl/up_test.go @@ -0,0 +1,263 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package clusterctrl + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/agiledragon/gomonkey/v2" + . "github.com/smartystreets/goconvey/convey" + + "cli/constant" + "cli/utils" +) + +func TestClusterCtrl(t *testing.T) { + Convey("Test ClusterCtrl functions", t, func() { + // 测试 Up 函数 + Convey("Up", func() { + Convey("Given a valid cluster config and successful executor", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master-ip"}, + AgentIp: []string{"agent1-ip", "agent2-ip"}, + MasterCmd: []string{"start-master"}, + AgentCmd: []string{"start-agent " + utils.PlaceholderMasterInfo}, + } + masterInfo := "master-info-data" + mock := &mockExecutor{} + patches := gomonkey.NewPatches() + patches.ApplyMethod(mock, "RunCommandsSync", func(_ *mockExecutor, host string, cmds []string) ([]byte, error) { + if host == "master-ip" && cmds[0] == "start-master" { + return nil, nil + } + return nil, errors.New("unexpected command") + }) + patches.ApplyMethod(mock, "RunCommandSync", func(_ *mockExecutor, host, cmd string) ([]byte, error) { + if host == "master-ip" && cmd == fmt.Sprintf("cat %s", constant.DefaultYuanRongCurrentMasterInfoPath) { + return []byte(masterInfo + "\n"), nil + } + return nil, errors.New("unexpected command") + }) + patches.ApplyMethod(mock, "RunCommandsOnHostsParallelSync", func(_ *mockExecutor, hosts []string, cmds []string) ([]result, error) { + if len(hosts) == 2 && hosts[0] == "agent1-ip" && hosts[1] == "agent2-ip" && cmds[0] == "start-agent "+masterInfo { + return []result{{Host: "agent1-ip"}, {Host: "agent2-ip"}}, nil + } + return nil, errors.New("unexpected hosts or commands") + }) + defer patches.Reset() + + // 重定向 stdout 以捕获输出 + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + defer func() { + err := w.Close() + if err != nil { + return + } + os.Stdout = origStdout + }() + + Convey("When Up is called", func() { + err := Up(cfg, mock) + + Convey("Then it should start the cluster successfully", func() { + So(err, ShouldBeNil) + + // 读取捕获的输出 + err := w.Close() + if err != nil { + return + } + var buf strings.Builder + _, _ = io.Copy(&buf, r) + output := buf.String() + So(output, ShouldContainSubstring, "succeed to start up master node, master info: "+masterInfo) + So(output, ShouldContainSubstring, "succeed to start up 2 agent nodes") + So(output, ShouldContainSubstring, "succeed to up a yuanrong cluster") + }) + }) + }) + + Convey("Given a config with invalid master IP count", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master1-ip", "master2-ip"}, // 多个 master IP + } + mock := &mockExecutor{} + + Convey("When Up is called", func() { + err := Up(cfg, mock) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "only accept one master ip") + }) + }) + }) + + Convey("Given a failure in upMasterNode", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master-ip"}, + MasterCmd: []string{"start-master"}, + } + mock := &mockExecutor{} + patches := gomonkey.ApplyMethod(mock, "RunCommandsSync", func(_ *mockExecutor, host string, cmds []string) ([]byte, error) { + return nil, errors.New("master startup failed") + }) + defer patches.Reset() + + Convey("When Up is called", func() { + err := Up(cfg, mock) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "master startup failed") + }) + }) + }) + }) + + // 测试 upMasterNode + Convey("upMasterNode", func() { + Convey("Given a valid config with one master IP", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master-ip"}, + MasterCmd: []string{"start-master"}, + } + masterInfo := "master-info-data" + mock := &mockExecutor{} + patches := gomonkey.NewPatches() + patches.ApplyMethod(mock, "RunCommandsSync", func(_ *mockExecutor, host string, cmds []string) ([]byte, error) { + return nil, nil + }) + patches.ApplyMethod(mock, "RunCommandSync", func(_ *mockExecutor, host, cmd string) ([]byte, error) { + if cmd == fmt.Sprintf("cat %s", constant.DefaultYuanRongCurrentMasterInfoPath) { + return []byte(masterInfo + "\n"), nil + } + return nil, errors.New("unexpected command") + }) + defer patches.Reset() + + Convey("When upMasterNode is called", func() { + result, err := upMasterNode(cfg, mock) + + Convey("Then it should return master info without error", func() { + So(err, ShouldBeNil) + So(result, ShouldEqual, masterInfo) + }) + }) + }) + + Convey("Given a config with multiple master IPs", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master1-ip", "master2-ip"}, + } + mock := &mockExecutor{} + + Convey("When upMasterNode is called", func() { + result, err := upMasterNode(cfg, mock) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "only accept one master ip") + So(result, ShouldEqual, "") + }) + }) + }) + + Convey("Given a failure to get master info", func() { + cfg := &utils.ClusterConfig{ + MasterIp: []string{"master-ip"}, + MasterCmd: []string{"start-master"}, + } + mock := &mockExecutor{} + patches := gomonkey.NewPatches() + patches.ApplyMethod(mock, "RunCommandsSync", func(_ *mockExecutor, host string, cmds []string) ([]byte, error) { + return nil, nil + }) + patches.ApplyMethod(mock, "RunCommandSync", func(_ *mockExecutor, host, cmd string) ([]byte, error) { + return nil, errors.New("cat failed") + }) + defer patches.Reset() + + Convey("When upMasterNode is called", func() { + result, err := upMasterNode(cfg, mock) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "failed to get master info") + So(result, ShouldEqual, "") + }) + }) + }) + }) + + // 测试 upAgentNode + Convey("upAgentNode", func() { + Convey("Given a valid config and master info", func() { + cfg := &utils.ClusterConfig{ + AgentIp: []string{"agent1-ip", "agent2-ip"}, + AgentCmd: []string{"start-agent " + utils.PlaceholderMasterInfo}, + } + masterInfo := "master-info-data" + mock := &mockExecutor{} + patches := gomonkey.ApplyMethod(mock, "RunCommandsOnHostsParallelSync", func(_ *mockExecutor, hosts []string, cmds []string) ([]result, error) { + if len(hosts) == 2 && hosts[0] == "agent1-ip" && hosts[1] == "agent2-ip" && cmds[0] == "start-agent "+masterInfo { + return []result{{Host: "agent1-ip"}, {Host: "agent2-ip"}}, nil + } + return nil, errors.New("unexpected hosts or commands") + }) + defer patches.Reset() + + Convey("When upAgentNode is called", func() { + err := upAgentNode(cfg, mock, masterInfo) + + Convey("Then it should start agent nodes without error", func() { + So(err, ShouldBeNil) + }) + }) + }) + + Convey("Given a failure in agent node startup", func() { + cfg := &utils.ClusterConfig{ + AgentIp: []string{"agent1-ip"}, + AgentCmd: []string{"start-agent " + utils.PlaceholderMasterInfo}, + } + masterInfo := "master-info-data" + mock := &mockExecutor{} + patches := gomonkey.ApplyMethod(mock, "RunCommandsOnHostsParallelSync", func(_ *mockExecutor, hosts []string, cmds []string) ([]result, error) { + return nil, errors.New("agent startup failed") + }) + defer patches.Reset() + + Convey("When upAgentNode is called", func() { + err := upAgentNode(cfg, mock, masterInfo) + + Convey("Then it should return an error", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "agent startup failed") + }) + }) + }) + }) + }) +} diff --git a/functionsystem/apps/cli/proto/message.proto b/functionsystem/apps/cli/proto/message.proto new file mode 100644 index 0000000000000000000000000000000000000000..42338309819bf5fd63fd171f2b5aa045b8041ffe --- /dev/null +++ b/functionsystem/apps/cli/proto/message.proto @@ -0,0 +1,35 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +syntax = "proto3"; + +package pb; +option go_package = "cli/internal/pb"; + + +message DebugInstanceInfo { + string instanceID = 1; + int32 pid = 2; + string debugServer = 3; + string status = 4; + string language = 5; +} + +message QueryDebugInstanceInfosResponse { + string requestID = 1; + int32 code = 2; + repeated DebugInstanceInfo debugInstanceInfos = 3; +} \ No newline at end of file diff --git a/functionsystem/apps/cli/utils/cluster.go b/functionsystem/apps/cli/utils/cluster.go new file mode 100755 index 0000000000000000000000000000000000000000..691c0b5c99d9c883fcfdc545f03e8376098636fe --- /dev/null +++ b/functionsystem/apps/cli/utils/cluster.go @@ -0,0 +1,121 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +import ( + "fmt" + "net" + "os" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + // PlaceholderTarget is the target host + PlaceholderTarget = "{{target}}" + // PlaceholderCommand is the target command used in ssh + PlaceholderCommand = "{{command}}" + // PlaceholderMasterInfo is the master info used in agent start command + PlaceholderMasterInfo = "{{master_info}}" + // PlaceholderClusterName is the master info used in stop command + PlaceholderClusterName = "{{cluster_id}}" +) + +// ClusterConfig Struct for cluster configuration +type ClusterConfig struct { + ClusterName string `yaml:"cluster_name"` + MasterIp []string `yaml:"master_ip"` + AgentIp []string `yaml:"agent_ip"` + MasterCmd []string `yaml:"master_cmd,omitempty"` + AgentCmd []string `yaml:"agent_cmd,omitempty"` + StopCmd []string `yaml:"stop_cmd,omitempty"` + SSHCmd string `yaml:"ssh_cmd,omitempty"` + Env []string `yaml:"environment,omitempty"` +} + +// NewDefaultClusterConfig make a config with default configs +func NewDefaultClusterConfig() *ClusterConfig { + return &ClusterConfig{ + ClusterName: time.Now().Format("20060102T150405"), + MasterCmd: []string{"yr start --master"}, + AgentCmd: []string{fmt.Sprintf("yr start --master_info=%s", PlaceholderMasterInfo)}, + StopCmd: []string{fmt.Sprintf("yr stop --cluster_name=%s", PlaceholderClusterName)}, + SSHCmd: fmt.Sprintf("ssh -q -n %s %s", PlaceholderTarget, PlaceholderCommand), + } +} + +// LoadClusterConfig Load the cluster configuration YAML file +func LoadClusterConfig(filename string) (*ClusterConfig, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read file: %v", err) + } + + clusterConfig := NewDefaultClusterConfig() + if err := yaml.Unmarshal(data, clusterConfig); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %v", err) + } + + if err := ValidateClusterConfig(*clusterConfig); err != nil { + return nil, err + } + + return clusterConfig, nil +} + +// ValidateClusterConfig Validate the cluster configuration struct +func ValidateClusterConfig(clusterConfig ClusterConfig) error { + // Validate required fields + if clusterConfig.ClusterName == "" { + return fmt.Errorf("field 'cluster_name' is required") + } + if len(clusterConfig.MasterIp) != 1 { + errMsg := "too many master_ip" + if len(clusterConfig.MasterIp) < 1 { + errMsg = "no master_ip provided" + } + return fmt.Errorf(errMsg) + } + if len(clusterConfig.AgentIp) == 0 { + return fmt.Errorf("field 'agent_ip' is required") + } + + // Validate ip address + for _, ip := range clusterConfig.MasterIp { + if net.ParseIP(ip).To4() == nil { + return fmt.Errorf("invalid IP address in master_ip: %s", ip) + } + } + for _, ip := range clusterConfig.AgentIp { + if net.ParseIP(ip).To4() == nil { + return fmt.Errorf("invalid IP address in agent_ip: %s", ip) + } + } + + return nil +} + +// ReplaceClusterConfigPlaceholderInAllCommands - +func ReplaceClusterConfigPlaceholderInAllCommands(commands []string, placeholder, value string) []string { + var replacedCommands []string + for _, cmd := range commands { + replacedCommands = append(replacedCommands, strings.ReplaceAll(cmd, placeholder, value)) + } + return replacedCommands +} diff --git a/functionsystem/apps/cli/utils/cluster_test.go b/functionsystem/apps/cli/utils/cluster_test.go new file mode 100755 index 0000000000000000000000000000000000000000..fc4fb195a5716de348058fbdb8efe5b5d2b81716 --- /dev/null +++ b/functionsystem/apps/cli/utils/cluster_test.go @@ -0,0 +1,218 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package utils + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/smartystreets/goconvey/convey" +) + +func TestLoadClusterConfig(t *testing.T) { + convey.Convey("Testing LoadConfig", t, func() { + // Test case 1: Normal case + convey.Convey("Given a valid YAML file", func() { + fileContent := ` +cluster_name: default +master_ip: + - 192.168.1.1 +agent_ip: + - 192.168.1.2 + - 192.168.1.3 +master_cmd: + - yr start --master +agent_cmd: + - ./program +` + filePath := "cluster-config-test.yaml" + err := ioutil.WriteFile(filePath, []byte(fileContent), 0o644) + convey.So(err, convey.ShouldBeNil) + convey.Reset(func() { + os.Remove(filePath) + }) + + config, err := LoadClusterConfig(filePath) + convey.So(err, convey.ShouldBeNil) + convey.So(config.ClusterName, convey.ShouldEqual, "default") + convey.So(config.MasterIp, convey.ShouldResemble, []string{"192.168.1.1"}) + convey.So(config.AgentIp, convey.ShouldResemble, []string{"192.168.1.2", "192.168.1.3"}) + convey.So(config.MasterCmd, convey.ShouldResemble, []string{"yr start --master"}) + convey.So(config.AgentCmd, convey.ShouldResemble, []string{"./program"}) + }) + + // Test case 2: Missing required fields - cluster_name + convey.Convey("Given a YAML file missing cluster_name", func() { + fileContent := ` +master_ip: + - 192.168.1.1 +agent_ip: + - 192.168.1.2 +` + filePath := "cluster-config-test.yaml" + err := ioutil.WriteFile(filePath, []byte(fileContent), 0o644) + convey.So(err, convey.ShouldBeNil) + convey.Reset(func() { + os.Remove(filePath) + }) + + _, err = LoadClusterConfig(filePath) + convey.So(err, convey.ShouldNotBeNil) + convey.So(err.Error(), convey.ShouldContainSubstring, "field 'cluster_name' is required") + }) + + // Test case 3: Missing required fields - master_ip + convey.Convey("Given a YAML file missing master_ip", func() { + fileContent := ` +cluster_name: default +agent_ip: + - 192.168.1.2 +` + filePath := "cluster-config-test.yaml" + err := ioutil.WriteFile(filePath, []byte(fileContent), 0o644) + convey.So(err, convey.ShouldBeNil) + convey.Reset(func() { + os.Remove(filePath) + }) + + _, err = LoadClusterConfig(filePath) + convey.So(err, convey.ShouldNotBeNil) + convey.So(err.Error(), convey.ShouldContainSubstring, "field 'master_ip' is required") + }) + + // Test case 4: Missing required fields - agent_ip + convey.Convey("Given a YAML file missing agent_ip", func() { + fileContent := ` +cluster_name: default + - 192.168.1.2 +master_ip: + - 192.168.1.1 +` + filePath := "cluster-config-test.yaml" + err := ioutil.WriteFile(filePath, []byte(fileContent), 0o644) + convey.So(err, convey.ShouldBeNil) + convey.Reset(func() { + os.Remove(filePath) + }) + + _, err = LoadClusterConfig(filePath) + convey.So(err, convey.ShouldNotBeNil) + convey.So(err.Error(), convey.ShouldContainSubstring, "field 'agent_ip' is required") + }) + + // Test case 5: master_cmd with multiple lines + convey.Convey("Given a YAML file with multiple lines in master_cmd", func() { + fileContent := ` +cluster_name: default +master_ip: +- 192.168.1.1 +agent_ip: +- 192.168.1.2 +master_cmd: +- yr start --master +- echo "master2" +` + filePath := "cluster-config-test.yaml" + err := ioutil.WriteFile(filePath, []byte(fileContent), 0o644) + convey.So(err, convey.ShouldBeNil) + convey.Reset(func() { + os.Remove(filePath) + }) + + _, err = LoadClusterConfig(filePath) + convey.So(err, convey.ShouldNotBeNil) + convey.So(err.Error(), convey.ShouldContainSubstring, "field 'master_cmd' can have at most one command") + }) + + // Test case 6: agent_cmd with multiple lines + convey.Convey("Given a YAML file with multiple lines in agent_cmd", func() { + fileContent := ` +cluster_name: default +master_ip: +- 192.168.1.1 +agent_ip: +- 192.168.1.2 +agent_cmd: +- yr start --master +- echo "agent2" +` + filePath := "cluster-config-test.yaml" + err := ioutil.WriteFile(filePath, []byte(fileContent), 0o644) + convey.So(err, convey.ShouldBeNil) + convey.Reset(func() { + os.Remove(filePath) + }) + + _, err = LoadClusterConfig(filePath) + convey.So(err, convey.ShouldNotBeNil) + convey.So(err.Error(), convey.ShouldContainSubstring, "field 'agent_cmd' can have at most one command") + }) + + // Test case 7: invalid MasterIp + convey.Convey("Given a YAML file with invalid master_ip", func() { + fileContent := ` +cluster_name: default +master_ip: +- 192.168.1.256 +agent_ip: +- 192.168.1.2 +` + filePath := "cluster-config-test.yaml" + err := ioutil.WriteFile(filePath, []byte(fileContent), 0o644) + convey.So(err, convey.ShouldBeNil) + convey.Reset(func() { + os.Remove(filePath) + }) + + _, err = LoadClusterConfig(filePath) + convey.So(err, convey.ShouldNotBeNil) + convey.So(err.Error(), convey.ShouldContainSubstring, "invalid IP address in master_ip") + }) + + // Test case 8: invalid AgentIp + convey.Convey("Given a YAML file with invalid agent_ip", func() { + fileContent := ` +cluster_name: default +master_ip: +- 192.168.1.1 +agent_ip: +- 192.168.1.x +` + filePath := "cluster-config-test.yaml" + err := ioutil.WriteFile(filePath, []byte(fileContent), 0o644) + convey.So(err, convey.ShouldBeNil) + convey.Reset(func() { + os.Remove(filePath) + }) + + _, err = LoadClusterConfig(filePath) + convey.So(err, convey.ShouldNotBeNil) + convey.So(err.Error(), convey.ShouldContainSubstring, "invalid IP address in agent_ip") + }) + + // Test case 9: File not found + convey.Convey("Given a non-existent YAML file", func() { + filePath := "nonexistent.yaml" + _, err := LoadClusterConfig(filePath) + convey.Reset(func() { + os.Remove(filePath) + }) + convey.So(err, convey.ShouldNotBeNil) + convey.So(err.Error(), convey.ShouldContainSubstring, "failed to read file") + }) + }) +} diff --git a/functionsystem/apps/cli/utils/cmd_args.go b/functionsystem/apps/cli/utils/cmd_args.go index 22c6eaec6a1561c9b6cf241b3d414cdf2a29194d..b2b870397fda007544b716d712db86b3f882ba5b 100644 --- a/functionsystem/apps/cli/utils/cmd_args.go +++ b/functionsystem/apps/cli/utils/cmd_args.go @@ -40,12 +40,13 @@ func GetGOOSType() (string, string) { if runtime.GOOS == "windows" { return "CMD", "/C" } - return "bash", "-c" + return "/bin/sh", "-c" } // ExecCommandUntil - this function will also check if the command exit, so it will return the channel with wait called func ExecCommandUntil(cmd *exec.Cmd, stopCond func(ctx context.Context, block bool) error, timeout int, block bool) ( - error, chan error) { + error, chan error, +) { if err := cmd.Start(); err != nil { fmt.Println("failed to start sub command:", err) return err, nil diff --git a/functionsystem/apps/cli/utils/table.go b/functionsystem/apps/cli/utils/table.go new file mode 100755 index 0000000000000000000000000000000000000000..0ee86493f690008a4e2395ae753c27711b32a0ac --- /dev/null +++ b/functionsystem/apps/cli/utils/table.go @@ -0,0 +1,32 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +import ( + "io" + + "github.com/olekukonko/tablewriter" +) + +// NewTable new tablewriter +func NewTable(writer io.Writer, border, rowLine, autoMergeCells bool) *tablewriter.Table { + table := tablewriter.NewWriter(writer) + table.SetBorder(border) + table.SetRowLine(rowLine) + table.SetAutoMergeCells(autoMergeCells) + return table +} diff --git a/functionsystem/apps/cli/utils/table_test.go b/functionsystem/apps/cli/utils/table_test.go new file mode 100755 index 0000000000000000000000000000000000000000..34b84582b0c07987c7ede022cd9be88552f8661c --- /dev/null +++ b/functionsystem/apps/cli/utils/table_test.go @@ -0,0 +1,55 @@ +/* + * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewTable(t *testing.T) { + testCases := []struct { + name string + headers []string + bulks [][]string + wantStd string + }{ + { + name: "test", + headers: []string{"test"}, + bulks: [][]string{{"test"}}, + wantStd: `+------+ +| TEST | ++------+ +| test | ++------+ +`, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + out := &bytes.Buffer{} + table := NewTable(out, true, true, true) + table.SetHeader(tt.headers) + table.AppendBulk(tt.bulks) + table.Render() + assert.Equal(t, tt.wantStd, out.String()) + }) + } +} diff --git a/scripts/executor/compile/compile_go.py b/scripts/executor/compile/compile_go.py index 71cb52241defcd127647f1ffcc517c068b042ae5..c0d04e9f754ef5c87ed7e6760faa032c20e17b14 100644 --- a/scripts/executor/compile/compile_go.py +++ b/scripts/executor/compile/compile_go.py @@ -12,24 +12,46 @@ def compile_cli(root_path): app_path = os.path.join(root_path, "functionsystem", "apps", "cli") main_path = os.path.join(app_path, "cmd", "main.go") output_path = os.path.join(root_path, "functionsystem", "output", "bin") + utils.sync_command( + cmd=["go", "install", "google.golang.org/protobuf/cmd/protoc-gen-go@latest"], + cwd=app_path, + env=os.environ, + ) + utils.sync_command( + cmd=["mkdir", "-p", f"{app_path}/internal/pb"], cwd=app_path, env=os.environ + ) + utils.sync_command( + cmd=[ + "protoc", + f"--proto_path={app_path}/proto", + f"--go_out={app_path}/internal/pb", + "--go_opt=paths=source_relative", + f"message.proto", + ], + cwd=app_path, + env=os.environ, + ) compile_golang(app_path, "yr", main_path, output_path) + def compile_meta_service(root_path): app_path = os.path.join(root_path, "functionsystem", "apps", "meta_service") utils.sync_command( cmd=[ - "bash", "gen/gen.sh", + "bash", + "gen/gen.sh", ], cwd=app_path, - env=os.environ + env=os.environ, ) main_path = os.path.join(app_path, "cmd", "main.go") output_path = os.path.join(root_path, "functionsystem", "output", "bin") compile_golang(app_path, "meta_service", main_path, output_path) -def compile_golang(app_path, app_name, main_path, output_path, - go_ldflags="-s -w", cgo_enabled=False): +def compile_golang( + app_path, app_name, main_path, output_path, go_ldflags="-s -w", cgo_enabled=False +): """ 在 app_path 路径下编译 main_path 到 output_path/app_name """ @@ -41,17 +63,21 @@ def compile_golang(app_path, app_name, main_path, output_path, log.info(f"Go build env: {json.dumps(build_env)}") utils.sync_command( cmd=[ - "go", "build", - "-o", bin_path, + "go", + "build", + "-o", + bin_path, "-trimpath", - "-ldflags", go_ldflags, - main_path + "-ldflags", + go_ldflags, + main_path, ], cwd=app_path, - env=build_env + env=build_env, ) log.info(f"Build golang app[{app_name}] success") + def compile_etcd(vendor_path): etcd_path = os.path.join(vendor_path, "src", "etcd") etcd_bin_path = os.path.join(etcd_path, "bin") @@ -59,7 +85,4 @@ def compile_etcd(vendor_path): log.warning("Skip ETCD compilation. Compilation product already exists") return # 原地编译ETCD - utils.sync_command( - ["bash", "build.sh"], - cwd=os.path.join(etcd_path) - ) \ No newline at end of file + utils.sync_command(["bash", "build.sh"], cwd=os.path.join(etcd_path))