diff --git a/backend/go.mod b/backend/go.mod index c7da08ebe..3155d8acc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,11 +2,49 @@ module github.com/Azure/ARO-HCP/backend go 1.23.0 -require github.com/spf13/cobra v1.8.1 +require ( + github.com/Azure/ARO-HCP/internal v0.0.0-00010101000000-000000000000 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 + github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos v1.1.0 + github.com/openshift-online/ocm-sdk-go v0.1.445 + github.com/spf13/cobra v1.8.1 +) require ( + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/golang/glog v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/prometheus/client_golang v1.20.4 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/oauth2 v0.22.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/Azure/ARO-HCP/internal => ../internal diff --git a/backend/go.sum b/backend/go.sum index 912390a78..cdb6a1cb5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,10 +1,147 @@ +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0/go.mod h1:YL1xnZ6QejvQHWJrX/AvhFl4WW4rqHVoKspWNVwFk0M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvULkDNfdXOgrjtg6UYJPFBJyuEcRCAw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ= +github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos v1.1.0 h1:c726lgbwpwFBuj+Fyrwuh/vUilqFo+hUAOUNjsKj5DI= +github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos v1.1.0/go.mod h1:WzFGxuepAtZIZtQbz8/WviJycLMKJHpaEAqcXONxlag= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= +github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY= +github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.7 h1:hYPTpeWfrJ1OT+2j6cvBScbhl0TkdwGM4bc66onUSOQ= +github.com/itchyny/gojq v0.12.7/go.mod h1:ZdvNHVlzPgUf8pgjnuDTmGfHA/21KoutQUJ3An/xNuw= +github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU= +github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= +github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= +github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.1.4 h1:GNapqRSid3zijZ9H77KrgVG4/8KqiyRsxcSxe+7ApXY= +github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/openshift-online/ocm-sdk-go v0.1.445 h1:NfaY+biXaREPnGYxa8G2zS2OZpN06yNnDR95sZoqKUQ= +github.com/openshift-online/ocm-sdk-go v0.1.445/go.mod h1:CiAu2jwl3ITKOxkeV0Qnhzv4gs35AmpIzVABQLtcI2Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= +github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= +github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 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/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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= diff --git a/backend/main.go b/backend/main.go index 3ff177757..88b455aa9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,6 +4,7 @@ package main // Licensed under the Apache License 2.0. import ( + "context" "fmt" "log/slog" "os" @@ -12,7 +13,14 @@ import ( "runtime/debug" "syscall" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos" + ocmsdk "github.com/openshift-online/ocm-sdk-go" "github.com/spf13/cobra" + + "github.com/Azure/ARO-HCP/internal/database" ) var ( @@ -64,20 +72,71 @@ func init() { } } +func newCosmosDBClient() (database.DBClient, error) { + azcoreClientOptions := azcore.ClientOptions{ + // FIXME Cloud should be determined by other means. + Cloud: cloud.AzurePublic, + } + + credential, err := azidentity.NewDefaultAzureCredential( + &azidentity.DefaultAzureCredentialOptions{ + ClientOptions: azcoreClientOptions, + }) + if err != nil { + return nil, err + } + + client, err := azcosmos.NewClient(argCosmosURL, credential, + &azcosmos.ClientOptions{ + ClientOptions: azcoreClientOptions, + }) + if err != nil { + return nil, err + } + + databaseClient, err := client.NewDatabase(argCosmosName) + if err != nil { + return nil, err + } + + return database.NewCosmosDBClient(context.Background(), databaseClient) +} + func Run(cmd *cobra.Command, args []string) error { handler := slog.NewJSONHandler(os.Stdout, nil) logger := slog.New(handler) + // Create database client + dbClient, err := newCosmosDBClient() + if err != nil { + return fmt.Errorf("Failed to create database client: %w", err) + } + + // Create OCM connection + ocmConnection, err := ocmsdk.NewUnauthenticatedConnectionBuilder(). + URL(argClustersServiceURL). + Insecure(argInsecure). + Build() + if err != nil { + return fmt.Errorf("Failed to create OCM connection: %w", err) + } + logger.Info(fmt.Sprintf("%s (%s) started", cmd.Short, cmd.Version)) + operationsScanner := NewOperationsScanner(dbClient, ocmConnection) + stop := make(chan struct{}) signalChannel := make(chan os.Signal, 1) signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM) + go operationsScanner.Run(logger, stop) + sig := <-signalChannel logger.Info(fmt.Sprintf("caught %s signal", sig)) close(stop) + operationsScanner.Join() + logger.Info(fmt.Sprintf("%s (%s) stopped", cmd.Short, cmd.Version)) return nil diff --git a/backend/operations_scanner.go b/backend/operations_scanner.go new file mode 100644 index 000000000..00e50076b --- /dev/null +++ b/backend/operations_scanner.go @@ -0,0 +1,352 @@ +package main + +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache License 2.0. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "time" + + ocmsdk "github.com/openshift-online/ocm-sdk-go" + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + ocmerrors "github.com/openshift-online/ocm-sdk-go/errors" + + "github.com/Azure/ARO-HCP/internal/api/arm" + "github.com/Azure/ARO-HCP/internal/database" + "github.com/Azure/ARO-HCP/internal/ocm" +) + +const ( + defaultCosmosOperationsPollInterval = 30 * time.Second + defaultClusterServicePollInterval = 10 * time.Second +) + +type OperationsScanner struct { + dbClient database.DBClient + lockClient *database.LockClient + clusterService ocm.ClusterServiceClient + activeOperations []*database.OperationDocument + notificationClient *http.Client + done chan struct{} +} + +func NewOperationsScanner(dbClient database.DBClient, ocmConnection *ocmsdk.Connection) *OperationsScanner { + return &OperationsScanner{ + dbClient: dbClient, + lockClient: dbClient.GetLockClient(), + clusterService: ocm.ClusterServiceClient{Conn: ocmConnection}, + activeOperations: make([]*database.OperationDocument, 0), + notificationClient: http.DefaultClient, + done: make(chan struct{}), + } +} + +func getInterval(envName string, defaultVal time.Duration, logger *slog.Logger) time.Duration { + if intervalString, ok := os.LookupEnv(envName); ok { + interval, err := time.ParseDuration(intervalString) + if err == nil { + return interval + } else { + logger.Warn(fmt.Sprintf("Cannot use %s: %s", envName, err.Error())) + } + } + return defaultVal +} + +func (s *OperationsScanner) Run(logger *slog.Logger, stop <-chan struct{}) { + defer close(s.done) + + var interval time.Duration + + interval = getInterval("COSMOS_OPERATIONS_POLL_INTERVAL", defaultCosmosOperationsPollInterval, logger) + logger.Info("Polling Cosmos Operations items every " + interval.String()) + pollDBOperationsTicker := time.NewTicker(interval) + + interval = getInterval("CLUSTER_SERVICE_POLL_INTERVAL", defaultClusterServicePollInterval, logger) + logger.Info("Polling Cluster Service every " + interval.String()) + pollCSOperationsTicker := time.NewTicker(interval) + + ctx := context.Background() + + // Poll database immediately on startup. + s.pollDBOperations(ctx, logger) + + for { + select { + case <-pollDBOperationsTicker.C: + s.pollDBOperations(ctx, logger) + case <-pollCSOperationsTicker.C: + s.pollCSOperations(ctx, logger, stop) + case <-stop: + break + } + } +} + +func (s *OperationsScanner) Join() { + <-s.done +} + +func (s *OperationsScanner) pollDBOperations(ctx context.Context, logger *slog.Logger) { + var activeOperations []*database.OperationDocument + + iterator := s.dbClient.ListAllOperationDocs(ctx) + + for item := range iterator.Items(ctx) { + var doc *database.OperationDocument + + err := json.Unmarshal(item, &doc) + if err != nil { + logger.Error(fmt.Sprintf("Failed to parse Operations container item: %s", err.Error())) + continue + } + + if !doc.Status.IsTerminal() { + activeOperations = append(activeOperations, doc) + } + } + + err := iterator.GetError() + if err == nil { + s.activeOperations = activeOperations + if len(s.activeOperations) > 0 { + logger.Info(fmt.Sprintf("Tracking %d active operations", len(s.activeOperations))) + } + } else { + logger.Error(fmt.Sprintf("Error while paging through Cosmos query results: %s", err.Error())) + } +} + +func (s *OperationsScanner) pollCSOperations(ctx context.Context, logger *slog.Logger, stop <-chan struct{}) { + var activeOperations []*database.OperationDocument + + for _, doc := range s.activeOperations { + select { + case <-stop: + break + default: + var requeue bool + var err error + + opLogger := logger.With( + "operation", doc.Request, + "operation_id", doc.ID, + "resource_id", doc.ExternalID.String(), + "internal_id", doc.InternalID.String()) + + switch doc.InternalID.Kind() { + case cmv1.ClusterKind: + requeue, err = s.pollClusterOperation(ctx, opLogger, doc) + case cmv1.NodePoolKind: + requeue, err = s.pollNodePoolOperation(ctx, opLogger, doc) + } + if requeue { + activeOperations = append(activeOperations, doc) + } + if err != nil { + opLogger.Error(fmt.Sprintf("Error while polling operation '%s': %s", doc.ID, err.Error())) + } + } + } + + s.activeOperations = activeOperations +} + +func (s *OperationsScanner) pollClusterOperation(ctx context.Context, logger *slog.Logger, doc *database.OperationDocument) (bool, error) { + var requeue bool = true + + clusterStatus, err := s.clusterService.GetCSClusterStatus(ctx, doc.InternalID) + if err != nil { + var ocmError *ocmerrors.Error + if errors.As(err, &ocmError) && ocmError.Status() == http.StatusNotFound && doc.Request == database.OperationRequestDelete { + err = s.withSubscriptionLock(ctx, logger, doc.OperationID.SubscriptionID, func(ctx context.Context) error { + return s.deleteOperationCompleted(ctx, logger, doc) + }) + if err == nil { + requeue = false + } + } + return requeue, err + } + + opStatus, opError, err := convertClusterStatus(clusterStatus, doc.Status) + if err != nil { + logger.Warn(err.Error()) + err = nil + } else { + err = s.withSubscriptionLock(ctx, logger, doc.OperationID.SubscriptionID, func(ctx context.Context) error { + return s.updateOperationStatus(ctx, logger, doc, opStatus, opError) + }) + } + + return requeue, err +} + +func (s *OperationsScanner) pollNodePoolOperation(ctx context.Context, logger *slog.Logger, doc *database.OperationDocument) (bool, error) { + var requeue bool = true + // FIXME Implement when new OCM API is available. + return requeue, nil +} + +func (s *OperationsScanner) withSubscriptionLock(ctx context.Context, logger *slog.Logger, subscriptionID string, fn func(ctx context.Context) error) error { + timeout := s.lockClient.GetDefaultTimeToLive() + lock, err := s.lockClient.AcquireLock(ctx, subscriptionID, &timeout) + if err != nil { + return fmt.Errorf("Failed to acquire lock for subscription '%s': %w", subscriptionID, err) + } + + lockedCtx, stop := s.lockClient.HoldLock(ctx, lock) + err = fn(lockedCtx) + lock = stop() + + if lock != nil { + nonFatalErr := s.lockClient.ReleaseLock(ctx, lock) + if nonFatalErr != nil { + // Failure here is non-fatal but still log the error. + // The lock's TTL ensures it will be released eventually. + logger.Error(fmt.Sprintf("Failed to release lock for subscription '%s': %v", subscriptionID, nonFatalErr)) + } + } + + return err +} + +func (s *OperationsScanner) deleteOperationCompleted(ctx context.Context, logger *slog.Logger, doc *database.OperationDocument) error { + err := s.dbClient.DeleteResourceDoc(ctx, doc.ExternalID) + if err != nil { + return err + } + + // Save a final "succeeded" operation status until TTL expires. + const opStatus arm.ProvisioningState = arm.ProvisioningStateSucceeded + updated, err := s.dbClient.UpdateOperationDoc(ctx, doc.ID, func(updateDoc *database.OperationDocument) bool { + return updateDoc.UpdateStatus(opStatus, nil) + }) + if err != nil { + return err + } + if updated { + logger.Info(fmt.Sprintf("Updated Operations container item for '%s' with status '%s'", doc.ID, opStatus)) + s.maybePostAsyncNotification(ctx, logger, doc) + } + + return nil +} + +func (s *OperationsScanner) updateOperationStatus(ctx context.Context, logger *slog.Logger, doc *database.OperationDocument, opStatus arm.ProvisioningState, opError *arm.CloudErrorBody) error { + updated, err := s.dbClient.UpdateOperationDoc(ctx, doc.ID, func(updateDoc *database.OperationDocument) bool { + return updateDoc.UpdateStatus(opStatus, opError) + }) + if err != nil { + return err + } + if updated { + logger.Info(fmt.Sprintf("Updated Operations container item for '%s' with status '%s'", doc.ID, opStatus)) + s.maybePostAsyncNotification(ctx, logger, doc) + } + + updated, err = s.dbClient.UpdateResourceDoc(ctx, doc.ExternalID, func(updateDoc *database.ResourceDocument) bool { + var updated bool + + if doc.ID == updateDoc.ActiveOperationID { + if opStatus != updateDoc.ProvisioningState { + updateDoc.ProvisioningState = opStatus + updated = true + } + if opStatus.IsTerminal() { + updateDoc.ActiveOperationID = "" + updated = true + } + } + + return updated + }) + if err != nil { + return err + } + if updated { + logger.Info(fmt.Sprintf("Updated Resources container item for '%s' with provisioning state '%s'", doc.ExternalID, opStatus)) + } + + return nil +} + +func (s *OperationsScanner) maybePostAsyncNotification(ctx context.Context, logger *slog.Logger, doc *database.OperationDocument) { + if len(doc.NotificationURI) > 0 { + err := s.postAsyncNotification(ctx, doc.NotificationURI) + if err == nil { + logger.Info(fmt.Sprintf("Posted async notification for operation '%s'", doc.ID)) + } else { + logger.Error(fmt.Sprintf("Failed to post async notification for operation '%s': %s", doc.ID, err.Error())) + } + } +} + +func (s *OperationsScanner) postAsyncNotification(ctx context.Context, url string) error { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return err + } + + response, err := s.notificationClient.Do(request) + if err != nil { + return err + } + + defer response.Body.Close() + if response.StatusCode >= 400 { + return errors.New(response.Status) + } + + return nil +} + +func convertClusterStatus(clusterStatus *cmv1.ClusterStatus, current arm.ProvisioningState) (arm.ProvisioningState, *arm.CloudErrorBody, error) { + var opStatus arm.ProvisioningState = current + var opError *arm.CloudErrorBody + var err error + + // FIXME This logic is all tenative until the new "/api/aro_hcp/v1" OCM + // API is available. What's here now is a best guess at converting + // ClusterStatus from the "/api/clusters_mgmt/v1" API. + + switch state := clusterStatus.State(); state { + case cmv1.ClusterStateError: + opStatus = arm.ProvisioningStateFailed + // FIXME This is guesswork. Need clarity from Cluster Service + // on what provision error codes are possible so we can + // translate to an appropriate cloud error code. + code := clusterStatus.ProvisionErrorCode() + if code == "" { + code = arm.CloudErrorCodeInternalServerError + } + message := clusterStatus.ProvisionErrorMessage() + if message == "" { + message = clusterStatus.Description() + } + opError = &arm.CloudErrorBody{Code: code, Message: message} + case cmv1.ClusterStateInstalling: + opStatus = arm.ProvisioningStateProvisioning + case cmv1.ClusterStateReady: + opStatus = arm.ProvisioningStateSucceeded + case cmv1.ClusterStateUninstalling: + opStatus = arm.ProvisioningStateDeleting + case cmv1.ClusterStatePending, cmv1.ClusterStateValidating: + // These are valid cluster states for ARO-HCP but there are + // no unique ProvisioningState values for them. They should + // only occur when ProvisioningState is Accepted. + if current != arm.ProvisioningStateAccepted { + err = fmt.Errorf("Got ClusterState '%s' while ProvisioningState was '%s' instead of '%s'", state, current, arm.ProvisioningStateAccepted) + } + default: + err = fmt.Errorf("Unhandled ClusterState '%s'", state) + } + + return opStatus, opError, err +} diff --git a/backend/operations_scanner_test.go b/backend/operations_scanner_test.go new file mode 100644 index 000000000..a84c5118f --- /dev/null +++ b/backend/operations_scanner_test.go @@ -0,0 +1,437 @@ +package main + +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache License 2.0. + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + + "github.com/Azure/ARO-HCP/internal/api/arm" + "github.com/Azure/ARO-HCP/internal/database" +) + +func TestDeleteOperationCompleted(t *testing.T) { + tests := []struct { + name string + operationStatus arm.ProvisioningState + resourceDocPresent bool + expectAsyncNotification bool + expectError bool + }{ + { + name: "Database updated properly", + operationStatus: arm.ProvisioningStateDeleting, + resourceDocPresent: true, + expectAsyncNotification: true, + expectError: false, + }, + { + name: "Resource already deleted", + operationStatus: arm.ProvisioningStateDeleting, + resourceDocPresent: false, + expectAsyncNotification: true, + expectError: false, + }, + { + name: "Operation already succeeded", + operationStatus: arm.ProvisioningStateSucceeded, + resourceDocPresent: true, + expectAsyncNotification: false, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var request *http.Request + + ctx := context.Background() + + resourceID, err := arm.ParseResourceID("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/testGroup/providers/Microsoft.RedHatOpenShift/hcpOpenShiftClusters/testCluster") + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + request = r + } + })) + defer server.Close() + + scanner := &OperationsScanner{ + dbClient: database.NewCache(), + notificationClient: server.Client(), + } + + operationDoc := database.NewOperationDocument(database.OperationRequestDelete) + operationDoc.ExternalID = resourceID + operationDoc.NotificationURI = server.URL + operationDoc.Status = tt.operationStatus + + _ = scanner.dbClient.CreateOperationDoc(ctx, operationDoc) + + if tt.resourceDocPresent { + resourceDoc := database.NewResourceDocument(resourceID) + _ = scanner.dbClient.CreateResourceDoc(ctx, resourceDoc) + } + + err = scanner.deleteOperationCompleted(ctx, slog.Default(), operationDoc) + + if request == nil && tt.expectAsyncNotification { + t.Error("Did not POST to async notification URI") + } else if request != nil && !tt.expectAsyncNotification { + t.Error("Unexpected POST to async notification URI") + } + + if err == nil && tt.expectError { + t.Error("Expected error but got none") + } else if err != nil && !tt.expectError { + t.Errorf("Got unexpected error: %v", err) + } + + if err == nil && tt.resourceDocPresent { + _, getErr := scanner.dbClient.GetResourceDoc(ctx, resourceID) + if !errors.Is(getErr, database.ErrNotFound) { + t.Error("Expected resource document to be deleted") + } + } + + if err == nil && tt.expectAsyncNotification { + operationDoc, getErr := scanner.dbClient.GetOperationDoc(ctx, operationDoc.ID) + if getErr != nil { + t.Fatal(getErr) + } + if operationDoc.Status != arm.ProvisioningStateSucceeded { + t.Errorf("Expected updated operation status to be %s but got %s", + arm.ProvisioningStateSucceeded, + operationDoc.Status) + } + } + }) + } +} + +func TestUpdateOperationStatus(t *testing.T) { + tests := []struct { + name string + currentOperationStatus arm.ProvisioningState + updatedOperationStatus arm.ProvisioningState + resourceDocPresent bool + resourceMatchOperationID bool + resourceProvisioningState arm.ProvisioningState + expectAsyncNotification bool + expectResourceOperationIDCleared bool + expectResourceProvisioningState arm.ProvisioningState + expectError bool + }{ + { + name: "Resource updated to terminal state", + currentOperationStatus: arm.ProvisioningStateProvisioning, + updatedOperationStatus: arm.ProvisioningStateSucceeded, + resourceDocPresent: true, + resourceMatchOperationID: true, + resourceProvisioningState: arm.ProvisioningStateProvisioning, + expectAsyncNotification: true, + expectResourceOperationIDCleared: true, + expectResourceProvisioningState: arm.ProvisioningStateSucceeded, + expectError: false, + }, + { + name: "Resource updated to non-terminal state", + currentOperationStatus: arm.ProvisioningStateSucceeded, + updatedOperationStatus: arm.ProvisioningStateDeleting, + resourceDocPresent: true, + resourceMatchOperationID: true, + resourceProvisioningState: arm.ProvisioningStateSucceeded, + expectAsyncNotification: true, + expectResourceOperationIDCleared: false, + expectResourceProvisioningState: arm.ProvisioningStateDeleting, + expectError: false, + }, + { + name: "Operation already at target provisioning state", + currentOperationStatus: arm.ProvisioningStateSucceeded, + updatedOperationStatus: arm.ProvisioningStateSucceeded, + resourceDocPresent: true, + resourceMatchOperationID: true, + resourceProvisioningState: arm.ProvisioningStateSucceeded, + expectAsyncNotification: false, + expectResourceOperationIDCleared: true, + expectResourceProvisioningState: arm.ProvisioningStateSucceeded, + expectError: false, + }, + { + name: "Resource not found", + currentOperationStatus: arm.ProvisioningStateProvisioning, + updatedOperationStatus: arm.ProvisioningStateSucceeded, + resourceDocPresent: false, + expectAsyncNotification: true, + expectError: true, + }, + { + name: "Resource has a different active operation", + currentOperationStatus: arm.ProvisioningStateProvisioning, + updatedOperationStatus: arm.ProvisioningStateSucceeded, + resourceDocPresent: true, + resourceMatchOperationID: false, + resourceProvisioningState: arm.ProvisioningStateDeleting, + expectAsyncNotification: true, + expectResourceOperationIDCleared: false, + expectResourceProvisioningState: arm.ProvisioningStateDeleting, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var request *http.Request + + ctx := context.Background() + + resourceID, err := arm.ParseResourceID("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/testGroup/providers/Microsoft.RedHatOpenShift/hcpOpenShiftClusters/testCluster") + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + request = r + } + })) + defer server.Close() + + scanner := &OperationsScanner{ + dbClient: database.NewCache(), + notificationClient: server.Client(), + } + + operationDoc := database.NewOperationDocument(database.OperationRequestCreate) + operationDoc.ExternalID = resourceID + operationDoc.NotificationURI = server.URL + operationDoc.Status = tt.currentOperationStatus + + _ = scanner.dbClient.CreateOperationDoc(ctx, operationDoc) + + if tt.resourceDocPresent { + resourceDoc := database.NewResourceDocument(resourceID) + if tt.resourceMatchOperationID { + resourceDoc.ActiveOperationID = operationDoc.ID + } else { + resourceDoc.ActiveOperationID = "another operation" + } + resourceDoc.ProvisioningState = tt.resourceProvisioningState + _ = scanner.dbClient.CreateResourceDoc(ctx, resourceDoc) + } + + err = scanner.updateOperationStatus(ctx, slog.Default(), operationDoc, tt.updatedOperationStatus, nil) + + if request == nil && tt.expectAsyncNotification { + t.Error("Did not POST to async notification URI") + } else if request != nil && !tt.expectAsyncNotification { + t.Error("Unexpected POST to async notification URI") + } + + if err == nil && tt.expectError { + t.Error("Expected error but got none") + } else if err != nil && !tt.expectError { + t.Errorf("Got unexpected error: %v", err) + } + + if err == nil && tt.expectAsyncNotification { + operationDoc, getErr := scanner.dbClient.GetOperationDoc(ctx, operationDoc.ID) + if getErr != nil { + t.Fatal(getErr) + } + if operationDoc.Status != tt.updatedOperationStatus { + t.Errorf("Expected updated operation status to be %s but got %s", + tt.updatedOperationStatus, + operationDoc.Status) + } + } + + if err == nil && tt.resourceDocPresent { + resourceDoc, getErr := scanner.dbClient.GetResourceDoc(ctx, resourceID) + if getErr != nil { + t.Fatal(getErr) + } + if resourceDoc.ActiveOperationID == "" && !tt.expectResourceOperationIDCleared { + t.Error("Resource's active operation ID is unexpectedly empty") + } else if resourceDoc.ActiveOperationID != "" && tt.expectResourceOperationIDCleared { + t.Errorf("Resource's active operation ID was not cleared; has '%s'", resourceDoc.ActiveOperationID) + } + if resourceDoc.ProvisioningState != tt.expectResourceProvisioningState { + t.Errorf("Expected updated provisioning state to be %s but got %s", + tt.expectResourceProvisioningState, + resourceDoc.ProvisioningState) + } + } + }) + } +} + +func TestConvertClusterStatus(t *testing.T) { + // FIXME These tests are all tentative until the new "/api/aro_hcp/v1" OCM + // API is available. What's here now is a best guess at converting + // ClusterStatus from the "/api/clusters_mgmt/v1" API. + // + // Also note, the particular error codes and messages to expect from + // Cluster Service is complete guesswork at the moment so we're only + // testing whether or not a cloud error is returned and not checking + // its content. + + tests := []struct { + name string + clusterState cmv1.ClusterState + currentProvisioningState arm.ProvisioningState + updatedProvisioningState arm.ProvisioningState + expectCloudError bool + expectConversionError bool + }{ + { + name: "Convert ClusterStateError", + clusterState: cmv1.ClusterStateError, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateFailed, + expectCloudError: true, + expectConversionError: false, + }, + { + name: "Convert ClusterStateHibernating", + clusterState: cmv1.ClusterStateHibernating, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateAccepted, + expectCloudError: false, + expectConversionError: true, + }, + { + name: "Convert ClusterStateInstalling", + clusterState: cmv1.ClusterStateInstalling, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateProvisioning, + expectCloudError: false, + expectConversionError: false, + }, + { + name: "Convert ClusterStatePending (while accepted)", + clusterState: cmv1.ClusterStatePending, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateAccepted, + expectCloudError: false, + expectConversionError: false, + }, + { + name: "Convert ClusterStatePending (while not accepted)", + clusterState: cmv1.ClusterStatePending, + currentProvisioningState: arm.ProvisioningStateFailed, + updatedProvisioningState: arm.ProvisioningStateFailed, + expectCloudError: false, + expectConversionError: true, + }, + { + name: "Convert ClusterStatePoweringDown", + clusterState: cmv1.ClusterStatePoweringDown, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateAccepted, + expectCloudError: false, + expectConversionError: true, + }, + { + name: "Convert ClusterStateReady", + clusterState: cmv1.ClusterStateReady, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateSucceeded, + expectCloudError: false, + expectConversionError: false, + }, + { + name: "Convert ClusterStateResuming", + clusterState: cmv1.ClusterStateResuming, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateAccepted, + expectCloudError: false, + expectConversionError: true, + }, + { + name: "Convert ClusterStateUninstalling", + clusterState: cmv1.ClusterStateUninstalling, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateDeleting, + expectCloudError: false, + expectConversionError: false, + }, + { + name: "Convert ClusterStateUnknown", + clusterState: cmv1.ClusterStateUnknown, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateAccepted, + expectCloudError: false, + expectConversionError: true, + }, + { + name: "Convert ClusterStateValidating (while accepted)", + clusterState: cmv1.ClusterStateValidating, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateAccepted, + expectCloudError: false, + expectConversionError: false, + }, + { + name: "Convert ClusterStateValidating (while not accepted)", + clusterState: cmv1.ClusterStateValidating, + currentProvisioningState: arm.ProvisioningStateFailed, + updatedProvisioningState: arm.ProvisioningStateFailed, + expectCloudError: false, + expectConversionError: true, + }, + { + name: "Convert ClusterStateWaiting", + clusterState: cmv1.ClusterStateWaiting, + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateAccepted, + expectCloudError: false, + expectConversionError: true, + }, + { + name: "Convert unexpected cluster state", + clusterState: cmv1.ClusterState("unexpected cluster state"), + currentProvisioningState: arm.ProvisioningStateAccepted, + updatedProvisioningState: arm.ProvisioningStateAccepted, + expectCloudError: false, + expectConversionError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clusterStatus, err := cmv1.NewClusterStatus(). + State(tt.clusterState). + Build() + if err != nil { + t.Fatal(err) + } + + opState, opError, err := convertClusterStatus(clusterStatus, tt.currentProvisioningState) + if opState != tt.updatedProvisioningState { + t.Errorf("Expected provisioning state '%s' but got '%s'", tt.updatedProvisioningState, opState) + } + if opError == nil && tt.expectCloudError { + t.Error("Expected a cloud error but got none") + } else if opError != nil && !tt.expectCloudError { + t.Errorf("Got unexpected cloud error: %v", opError) + } + if err == nil && tt.expectConversionError { + t.Error("Expected a conversion error but got none") + } else if err != nil && !tt.expectConversionError { + t.Errorf("Got unexpected conversion error: %v", err) + } + }) + } +} diff --git a/frontend/pkg/frontend/frontend.go b/frontend/pkg/frontend/frontend.go index 539e1a48f..1d01a4ae8 100644 --- a/frontend/pkg/frontend/frontend.go +++ b/frontend/pkg/frontend/frontend.go @@ -16,7 +16,6 @@ import ( "strconv" "strings" "sync/atomic" - "time" cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" "golang.org/x/sync/errgroup" @@ -593,12 +592,7 @@ func (f *Frontend) ArmResourceDelete(writer http.ResponseWriter, request *http.R // Deletion is underway; mark any active operation as canceled. if resourceDoc.ActiveOperationID != "" { updated, err := f.dbClient.UpdateOperationDoc(ctx, resourceDoc.ActiveOperationID, func(updateDoc *database.OperationDocument) bool { - if updateDoc.Status != arm.ProvisioningStateCanceled { - updateDoc.LastTransitionTime = time.Now() - updateDoc.Status = arm.ProvisioningStateCanceled - return true - } - return false + return updateDoc.UpdateStatus(arm.ProvisioningStateCanceled, nil) }) if err != nil { f.logger.Error(err.Error()) diff --git a/frontend/pkg/frontend/ocm.go b/frontend/pkg/frontend/ocm.go index 6b7c3a106..29e1c5b97 100644 --- a/frontend/pkg/frontend/ocm.go +++ b/frontend/pkg/frontend/ocm.go @@ -43,6 +43,14 @@ func convertVisibilityToListening(visibility api.Visibility) (listening cmv1.Lis // ConvertCStoHCPOpenShiftCluster converts a CS Cluster object into HCPOpenShiftCluster object func ConvertCStoHCPOpenShiftCluster(resourceID *arm.ResourceID, cluster *cmv1.Cluster) *api.HCPOpenShiftCluster { + // A word about ProvisioningState: + // ProvisioningState is stored in Cosmos and is applied to the + // HCPOpenShiftCluster struct along with the ARM metadata that + // is also stored in Cosmos. We could convert the ClusterState + // from Cluster Service to a ProvisioningState, but instead we + // defer that to the backend pod so that the ProvisioningState + // stays consistent with the Status of any active non-terminal + // operation on the cluster. hcpcluster := &api.HCPOpenShiftCluster{ TrackedResource: arm.TrackedResource{ Location: cluster.Region().ID(), @@ -53,7 +61,6 @@ func ConvertCStoHCPOpenShiftCluster(resourceID *arm.ResourceID, cluster *cmv1.Cl }, }, Properties: api.HCPOpenShiftClusterProperties{ - // ProvisioningState: cluster.State(), // TODO: align with OCM on ProvisioningState Spec: api.ClusterSpec{ Version: api.VersionProfile{ ID: cluster.Version().ID(), @@ -215,7 +222,6 @@ func ConvertCStoNodePool(resourceID *arm.ResourceID, np *cmv1.NodePool) *api.HCP }, }, Properties: api.HCPOpenShiftClusterNodePoolProperties{ - // ProvisioningState: np.Status(), // TODO: Align with OCM on aligning with ProvisioningState Spec: api.NodePoolSpec{ Version: api.VersionProfile{ ID: np.Version().ID(), diff --git a/go.work.sum b/go.work.sum index c815b8641..a4332a4ac 100644 --- a/go.work.sum +++ b/go.work.sum @@ -779,7 +779,6 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -1158,13 +1157,11 @@ github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108 github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM= github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/open-policy-agent/opa v0.42.2/go.mod h1:MrmoTi/BsKWT58kXlVayBb+rYVeaMwuBm3nYAN3923s= @@ -1215,6 +1212,7 @@ github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrb github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= diff --git a/internal/database/cache.go b/internal/database/cache.go index 5f27ea1f4..63b6ca620 100644 --- a/internal/database/cache.go +++ b/internal/database/cache.go @@ -5,6 +5,8 @@ package database import ( "context" + "encoding/json" + "iter" "strings" azcorearm "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" @@ -22,6 +24,34 @@ type Cache struct { subscription map[string]*SubscriptionDocument } +type operationCacheIterator struct { + operation map[string]*OperationDocument + err error +} + +func (iter operationCacheIterator) Items(ctx context.Context) iter.Seq[[]byte] { + return func(yield func([]byte) bool) { + for _, doc := range iter.operation { + // Marshalling the document struct only to immediately unmarshal + // it back to a document struct is a little silly but this is to + // conform to the DBClientIterator interface. + item, err := json.Marshal(doc) + if err != nil { + iter.err = err + return + } + + if !yield(item) { + return + } + } + } +} + +func (iter operationCacheIterator) GetError() error { + return iter.err +} + // NewCache initializes a new Cache to allow for simple tests without needing a real CosmosDB. For production, use // NewCosmosDBConfig instead. func NewCache() DBClient { @@ -133,6 +163,10 @@ func (c *Cache) DeleteOperationDoc(ctx context.Context, operationID string) erro return nil } +func (c *Cache) ListAllOperationDocs(ctx context.Context) DBClientIterator { + return operationCacheIterator{operation: c.operation} +} + func (c *Cache) GetSubscriptionDoc(ctx context.Context, subscriptionID string) (*SubscriptionDocument, error) { // Make sure lookup keys are lowercase. key := strings.ToLower(subscriptionID) diff --git a/internal/database/database.go b/internal/database/database.go index f05ccc9a9..85b7c7348 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "iter" "net/http" "strings" @@ -48,6 +49,11 @@ func isResponseError(err error, statusCode int) bool { return errors.As(err, &responseError) && responseError.StatusCode == statusCode } +type DBClientIterator interface { + Items(ctx context.Context) iter.Seq[[]byte] + GetError() error +} + // DBClient is a document store for frontend to perform required CRUD operations against type DBClient interface { // DBConnectionTest is used to health check the database. If the database is not reachable or otherwise not ready @@ -71,6 +77,7 @@ type DBClient interface { CreateOperationDoc(ctx context.Context, doc *OperationDocument) error UpdateOperationDoc(ctx context.Context, operationID string, callback func(*OperationDocument) bool) (bool, error) DeleteOperationDoc(ctx context.Context, operationID string) error + ListAllOperationDocs(ctx context.Context) DBClientIterator // GetSubscriptionDoc retrieves a SubscriptionDocument from the database given the subscriptionID. // ErrNotFound is returned if an associated SubscriptionDocument cannot be found. @@ -421,6 +428,11 @@ func (d *CosmosDBClient) DeleteOperationDoc(ctx context.Context, operationID str return nil } +func (d *CosmosDBClient) ListAllOperationDocs(ctx context.Context) DBClientIterator { + pk := azcosmos.NewPartitionKeyString(operationsPartitionKey) + return NewQueryItemsIterator(d.operations.NewQueryItemsPager("SELECT * FROM c", pk, nil)) +} + // GetSubscriptionDoc retreives a subscription document from async DB using the subscription ID func (d *CosmosDBClient) GetSubscriptionDoc(ctx context.Context, subscriptionID string) (*SubscriptionDocument, error) { // Make sure lookup keys are lowercase. diff --git a/internal/database/document.go b/internal/database/document.go index 32c5570ed..f40021592 100644 --- a/internal/database/document.go +++ b/internal/database/document.go @@ -132,6 +132,20 @@ func (doc *OperationDocument) ToStatus() *arm.Operation { return operation } +// UpdateStatus conditionally updates the document if the status given differs +// from the status already present. If so, it sets the Status and Error fields +// to the values given, updates the LastTransitionTime, and returns true. This +// is intended to be used with DBClient.UpdateOperationDoc. +func (doc *OperationDocument) UpdateStatus(status arm.ProvisioningState, err *arm.CloudErrorBody) bool { + if doc.Status != status { + doc.LastTransitionTime = time.Now() + doc.Status = status + doc.Error = err + return true + } + return false +} + // SubscriptionDocument represents an Azure Subscription document. type SubscriptionDocument struct { BaseDocument diff --git a/internal/database/util.go b/internal/database/util.go new file mode 100644 index 000000000..2ccbec4e4 --- /dev/null +++ b/internal/database/util.go @@ -0,0 +1,47 @@ +package database + +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache License 2.0. + +import ( + "context" + "iter" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/data/azcosmos" +) + +type QueryItemsIterator struct { + pager *runtime.Pager[azcosmos.QueryItemsResponse] + err error +} + +// NewQueryItemsIterator is a failable push iterator for a paged query response. +func NewQueryItemsIterator(pager *runtime.Pager[azcosmos.QueryItemsResponse]) QueryItemsIterator { + return QueryItemsIterator{pager: pager} +} + +// Items returns a push iterator that can be used directly in for/range loops. +// If an error occurs during paging, iteration stops and the error is recorded. +func (iter QueryItemsIterator) Items(ctx context.Context) iter.Seq[[]byte] { + return func(yield func([]byte) bool) { + for iter.pager.More() { + response, err := iter.pager.NextPage(ctx) + if err != nil { + iter.err = err + return + } + for _, item := range response.Items { + if !yield(item) { + return + } + } + } + } +} + +// GetError returns any error that occurred during iteration. Call this after the +// for/range loop that calls Items() to check if iteration completed successfully. +func (iter QueryItemsIterator) GetError() error { + return iter.err +} diff --git a/internal/ocm/mock.go b/internal/ocm/mock.go new file mode 100644 index 000000000..2081d2517 --- /dev/null +++ b/internal/ocm/mock.go @@ -0,0 +1,135 @@ +package ocm + +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache License 2.0. + +import ( + "context" + "fmt" + + sdk "github.com/openshift-online/ocm-sdk-go" + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + "github.com/openshift-online/ocm-sdk-go/errors" +) + +// MockClusterServiceClient allows for unit testing functions +// that make calls to the ClusterServiceClient interface. +type MockClusterServiceClient struct { + clusters map[InternalID](*cmv1.Cluster) + nodePools map[InternalID](*cmv1.NodePool) +} + +// mockNotFoundError is based on errors.SendNotFound. +func mockNotFoundError(internalID InternalID) error { + reason := fmt.Sprintf("Can't find resource for path '%s'", internalID) + // ErrorBuilder.Build() never returns an error. + body, _ := errors.NewError(). + ID("404"). + Reason(reason). + Build() + return body +} + +// NewCache initializes a new Cache to allow for simple tests without needing a real CosmosDB. For production, use +// NewCosmosDBConfig instead. +func NewMockClusterServiceClient() MockClusterServiceClient { + return MockClusterServiceClient{ + clusters: make(map[InternalID]*cmv1.Cluster), + nodePools: make(map[InternalID]*cmv1.NodePool), + } +} + +func (mcsc *MockClusterServiceClient) GetConn() *sdk.Connection { panic("GetConn not implemented") } + +func (csc *MockClusterServiceClient) AddProperties(builder *cmv1.ClusterBuilder) *cmv1.ClusterBuilder { + additionalProperties := getDefaultAdditionalProperities() + return builder.Properties(additionalProperties) +} + +func (mcsc *MockClusterServiceClient) GetCSCluster(ctx context.Context, internalID InternalID) (*cmv1.Cluster, error) { + cluster, ok := mcsc.clusters[internalID] + + if !ok { + return nil, mockNotFoundError(internalID) + } + return cluster, nil +} + +func (mcsc *MockClusterServiceClient) PostCSCluster(ctx context.Context, cluster *cmv1.Cluster) (*cmv1.Cluster, error) { + href := GenerateClusterHREF(cluster.Name()) + // Adding the HREF to correspond with what the full client does when crating the body + clusterBuilder := cmv1.NewCluster() + enrichedCluster, err := clusterBuilder.Copy(cluster).HREF(href).Build() + if err != nil { + return nil, err + } + internalID, err := NewInternalID(href) + if err != nil { + return nil, err + } + mcsc.clusters[internalID] = enrichedCluster + return enrichedCluster, nil +} + +func (mcsc *MockClusterServiceClient) UpdateCSCluster(ctx context.Context, internalID InternalID, cluster *cmv1.Cluster) (*cmv1.Cluster, error) { + + _, ok := mcsc.clusters[internalID] + if !ok { + return nil, mockNotFoundError(internalID) + } + mcsc.clusters[internalID] = cluster + return cluster, nil + +} + +func (mcsc *MockClusterServiceClient) DeleteCSCluster(ctx context.Context, internalID InternalID) error { + _, ok := mcsc.clusters[internalID] + if !ok { + return mockNotFoundError(internalID) + } + delete(mcsc.clusters, internalID) + return nil +} + +func (mcsc *MockClusterServiceClient) GetCSNodePool(ctx context.Context, internalID InternalID) (*cmv1.NodePool, error) { + nodePool, ok := mcsc.nodePools[internalID] + if !ok { + return nil, mockNotFoundError(internalID) + } + return nodePool, nil + +} + +func (mcsc *MockClusterServiceClient) PostCSNodePool(ctx context.Context, clusterInternalID InternalID, nodePool *cmv1.NodePool) (*cmv1.NodePool, error) { + href := GenerateNodePoolHREF(clusterInternalID.path, nodePool.ID()) + // Adding the HREF to correspond with what the full client does when crating the body + npBuilder := cmv1.NewNodePool() + enrichedNodePool, err := npBuilder.Copy(nodePool).HREF(href).Build() + if err != nil { + return nil, err + } + internalID, err := NewInternalID(href) + if err != nil { + return nil, err + } + mcsc.nodePools[internalID] = enrichedNodePool + return enrichedNodePool, nil +} + +func (mcsc *MockClusterServiceClient) UpdateCSNodePool(ctx context.Context, internalID InternalID, nodePool *cmv1.NodePool) (*cmv1.NodePool, error) { + _, ok := mcsc.nodePools[internalID] + if !ok { + return nil, mockNotFoundError(internalID) + } + mcsc.nodePools[internalID] = nodePool + return nodePool, nil +} + +func (mcsc *MockClusterServiceClient) DeleteCSNodePool(ctx context.Context, internalID InternalID) error { + _, ok := mcsc.nodePools[internalID] + if !ok { + return mockNotFoundError(internalID) + } + delete(mcsc.nodePools, internalID) + return nil +} diff --git a/internal/ocm/ocm.go b/internal/ocm/ocm.go index df2e60942..6c5dc2fea 100644 --- a/internal/ocm/ocm.go +++ b/internal/ocm/ocm.go @@ -96,6 +96,23 @@ func (csc *ClusterServiceClient) GetCSCluster(ctx context.Context, internalID In return cluster, nil } +// GetCSClusterStatus creates and sends a GET request to fetch a cluster's status from Clusters Service +func (csc *ClusterServiceClient) GetCSClusterStatus(ctx context.Context, internalID InternalID) (*cmv1.ClusterStatus, error) { + client, ok := internalID.GetClusterClient(csc.Conn) + if !ok { + return nil, fmt.Errorf("OCM path is not a cluster: %s", internalID) + } + clusterStatusGetResponse, err := client.Status().Get().SendContext(ctx) + if err != nil { + return nil, err + } + status, ok := clusterStatusGetResponse.GetBody() + if !ok { + return nil, fmt.Errorf("empty response body") + } + return status, nil +} + // PostCSCluster creates and sends a POST request to create a cluster in Clusters Service func (csc *ClusterServiceClient) PostCSCluster(ctx context.Context, cluster *cmv1.Cluster) (*cmv1.Cluster, error) { clustersAddResponse, err := csc.Conn.ClustersMgmt().V1().Clusters().Add().Body(cluster).SendContext(ctx) @@ -196,117 +213,3 @@ func (csc *ClusterServiceClient) DeleteCSNodePool(ctx context.Context, internalI _, err := client.Delete().SendContext(ctx) return err } - -type MockClusterServiceClient struct { - clusters map[InternalID](*cmv1.Cluster) - nodePools map[InternalID](*cmv1.NodePool) -} - -// NewCache initializes a new Cache to allow for simple tests without needing a real CosmosDB. For production, use -// NewCosmosDBConfig instead. -func NewMockClusterServiceClient() MockClusterServiceClient { - return MockClusterServiceClient{ - clusters: make(map[InternalID]*cmv1.Cluster), - nodePools: make(map[InternalID]*cmv1.NodePool), - } -} - -func (mcsc *MockClusterServiceClient) GetConn() *sdk.Connection { panic("GetConn not implemented") } - -func (csc *MockClusterServiceClient) AddProperties(builder *cmv1.ClusterBuilder) *cmv1.ClusterBuilder { - additionalProperties := getDefaultAdditionalProperities() - return builder.Properties(additionalProperties) -} - -func (mcsc *MockClusterServiceClient) GetCSCluster(ctx context.Context, internalID InternalID) (*cmv1.Cluster, error) { - cluster, ok := mcsc.clusters[internalID] - - if !ok { - return nil, fmt.Errorf("empty response body") - } - return cluster, nil -} - -func (mcsc *MockClusterServiceClient) PostCSCluster(ctx context.Context, cluster *cmv1.Cluster) (*cmv1.Cluster, error) { - href := GenerateClusterHREF(cluster.Name()) - // Adding the HREF to correspond with what the full client does when crating the body - clusterBuilder := cmv1.NewCluster() - enrichedCluster, err := clusterBuilder.Copy(cluster).HREF(href).Build() - if err != nil { - return nil, err - } - internalID, err := NewInternalID(href) - if err != nil { - return nil, err - } - mcsc.clusters[internalID] = enrichedCluster - return enrichedCluster, nil -} - -func (mcsc *MockClusterServiceClient) UpdateCSCluster(ctx context.Context, internalID InternalID, cluster *cmv1.Cluster) (*cmv1.Cluster, error) { - - _, ok := mcsc.clusters[internalID] - if !ok { - return nil, fmt.Errorf("Not Found") - } - - mcsc.clusters[internalID] = cluster - return cluster, nil - -} - -func (mcsc *MockClusterServiceClient) DeleteCSCluster(ctx context.Context, internalID InternalID) error { - _, ok := mcsc.clusters[internalID] - - if !ok { - return fmt.Errorf("Not Found") - } - delete(mcsc.clusters, internalID) - return nil -} - -func (mcsc *MockClusterServiceClient) GetCSNodePool(ctx context.Context, internalID InternalID) (*cmv1.NodePool, error) { - nodePool, ok := mcsc.nodePools[internalID] - - if !ok { - return nil, fmt.Errorf("empty response body") - } - return nodePool, nil - -} - -func (mcsc *MockClusterServiceClient) PostCSNodePool(ctx context.Context, clusterInternalID InternalID, nodePool *cmv1.NodePool) (*cmv1.NodePool, error) { - href := GenerateNodePoolHREF(clusterInternalID.path, nodePool.ID()) - // Adding the HREF to correspond with what the full client does when crating the body - npBuilder := cmv1.NewNodePool() - enrichedNodePool, err := npBuilder.Copy(nodePool).HREF(href).Build() - if err != nil { - return nil, err - } - - internalID, err := NewInternalID(href) - if err != nil { - return nil, err - } - mcsc.nodePools[internalID] = enrichedNodePool - return enrichedNodePool, nil -} - -func (mcsc *MockClusterServiceClient) UpdateCSNodePool(ctx context.Context, internalID InternalID, nodePool *cmv1.NodePool) (*cmv1.NodePool, error) { - _, ok := mcsc.nodePools[internalID] - if !ok { - return nil, fmt.Errorf("Not Found") - } - mcsc.nodePools[internalID] = nodePool - return nodePool, nil -} - -func (mcsc *MockClusterServiceClient) DeleteCSNodePool(ctx context.Context, internalID InternalID) error { - _, ok := mcsc.nodePools[internalID] - - if !ok { - return fmt.Errorf("Not Found") - } - delete(mcsc.nodePools, internalID) - return nil -}