From dda1da5bd610789484534d64d58609c4479f63c8 Mon Sep 17 00:00:00 2001 From: rverdile Date: Wed, 15 Jan 2025 15:39:14 -0500 Subject: [PATCH] HMS-5292: Add list subs-as-features --- .mockery.yaml | 3 + pkg/admin_client/admin_client_mock.go | 63 +++++++++++++ pkg/admin_client/client.go | 128 ++++++++++++++++++++++++++ pkg/api/admin_task.go | 4 + pkg/candlepin_client/client.go | 29 +----- pkg/config/certificates.go | 1 + pkg/config/config.go | 65 +++++++++++-- pkg/handler/admin_tasks.go | 23 ++++- pkg/handler/admin_tasks_test.go | 29 +++++- pkg/handler/api.go | 7 +- 10 files changed, 317 insertions(+), 35 deletions(-) create mode 100644 pkg/admin_client/admin_client_mock.go create mode 100644 pkg/admin_client/client.go create mode 100644 pkg/config/certificates.go diff --git a/.mockery.yaml b/.mockery.yaml index 34b0ebccf..b2c672db6 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -22,6 +22,9 @@ packages: github.com/content-services/content-sources-backend/pkg/candlepin_client: interfaces: CandlepinClient: + github.com/content-services/content-sources-backend/pkg/admin_client: + interfaces: + AdminClient: github.com/content-services/content-sources-backend/pkg/cache: interfaces: Cache: diff --git a/pkg/admin_client/admin_client_mock.go b/pkg/admin_client/admin_client_mock.go new file mode 100644 index 000000000..3d3baf18a --- /dev/null +++ b/pkg/admin_client/admin_client_mock.go @@ -0,0 +1,63 @@ +// Code generated by mockery. DO NOT EDIT. + +package admin_client + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockAdminClient is an autogenerated mock type for the AdminClient type +type MockAdminClient struct { + mock.Mock +} + +// ListFeatures provides a mock function with given fields: ctx +func (_m *MockAdminClient) ListFeatures(ctx context.Context) (FeaturesResponse, int, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ListFeatures") + } + + var r0 FeaturesResponse + var r1 int + var r2 error + if rf, ok := ret.Get(0).(func(context.Context) (FeaturesResponse, int, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) FeaturesResponse); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(FeaturesResponse) + } + + if rf, ok := ret.Get(1).(func(context.Context) int); ok { + r1 = rf(ctx) + } else { + r1 = ret.Get(1).(int) + } + + if rf, ok := ret.Get(2).(func(context.Context) error); ok { + r2 = rf(ctx) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewMockAdminClient creates a new instance of MockAdminClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAdminClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAdminClient { + mock := &MockAdminClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/admin_client/client.go b/pkg/admin_client/client.go new file mode 100644 index 000000000..f836db7c4 --- /dev/null +++ b/pkg/admin_client/client.go @@ -0,0 +1,128 @@ +package admin_client + +import ( + "context" + "encoding/json" + "fmt" + "github.com/content-services/content-sources-backend/pkg/config" + "io" + "net/http" + "os" + "time" +) + +type AdminClient interface { + ListFeatures(ctx context.Context) (features FeaturesResponse, statusCode int, err error) +} + +type adminClientImpl struct { + client http.Client +} + +func NewAdminClient() (AdminClient, error) { + httpClient, err := getHTTPClient() + if err != nil { + return nil, err + } + return adminClientImpl{client: httpClient}, nil +} + +type FeaturesResponse struct { + Content []Content `json:"content"` +} + +type Content struct { + Name string `json:"name"` + Rules Rules `json:"rules"` +} + +type Rules struct { + MatchProducts []MatchProducts `json:"matchProducts"` +} + +type MatchProducts struct { + EngIDs []int `json:"engIds"` +} + +func (ac adminClientImpl) ListFeatures(ctx context.Context) (FeaturesResponse, int, error) { + statusCode := http.StatusInternalServerError + var err error + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, config.Get().Clients.SubsAsFeatures.Server, nil) + if err != nil { + return FeaturesResponse{}, 0, err + } + + var body []byte + resp, err := ac.client.Do(req) + if resp != nil { + defer resp.Body.Close() + + if resp.StatusCode != 0 { + statusCode = resp.StatusCode + } + + body, err = io.ReadAll(resp.Body) + if err != nil { + return FeaturesResponse{}, http.StatusInternalServerError, fmt.Errorf("error during read response body: %w", err) + } + } + if err != nil { + return FeaturesResponse{}, statusCode, fmt.Errorf("error during GET request: %w", err) + } + if statusCode < 200 || statusCode >= 300 { + return FeaturesResponse{}, statusCode, fmt.Errorf("unexpected status code with body: %s", string(body)) + } + + var featResp FeaturesResponse + err = json.Unmarshal(body, &featResp) + if err != nil { + return FeaturesResponse{}, statusCode, fmt.Errorf("error during unmarshal response body: %w", err) + } + + return featResp, statusCode, nil +} + +func getHTTPClient() (http.Client, error) { + timeout := 90 * time.Second + + var cert []byte + if config.Get().Clients.SubsAsFeatures.ClientCert != "" { + cert = []byte(config.Get().Clients.SubsAsFeatures.ClientCert) + } else if config.Get().Clients.SubsAsFeatures.ClientCertPath != "" { + file, err := os.ReadFile(config.Get().Clients.SubsAsFeatures.ClientCertPath) + if err != nil { + return http.Client{}, err + } + cert = file + } + + var key []byte + if config.Get().Clients.SubsAsFeatures.ClientKey != "" { + key = []byte(config.Get().Clients.SubsAsFeatures.ClientKey) + } else if config.Get().Clients.SubsAsFeatures.ClientKeyPath != "" { + file, err := os.ReadFile(config.Get().Clients.SubsAsFeatures.ClientKeyPath) + if err != nil { + return http.Client{}, err + } + key = file + } + + var caCert []byte + if config.Get().Clients.SubsAsFeatures.CACert != "" { + caCert = []byte(config.Get().Clients.SubsAsFeatures.CACert) + } else if config.Get().Clients.SubsAsFeatures.CACertPath != "" { + file, err := os.ReadFile(config.Get().Clients.SubsAsFeatures.CACertPath) + if err != nil { + return http.Client{}, err + } + caCert = file + } + + transport, err := config.GetTransport(cert, key, caCert, timeout) + if err != nil { + return http.Client{}, fmt.Errorf("error creating http transport: %w", err) + } + + return http.Client{Transport: transport, Timeout: timeout}, nil +} diff --git a/pkg/api/admin_task.go b/pkg/api/admin_task.go index 752cae990..732c6f4d2 100644 --- a/pkg/api/admin_task.go +++ b/pkg/api/admin_task.go @@ -80,6 +80,10 @@ type pulpProgressReportResponse struct { Suffix zest.NullableString `json:"suffix,omitempty"` } +type SubsAsFeaturesResponse struct { + Features []string `json:"features"` +} + func (a *AdminTaskInfoCollectionResponse) SetMetadata(meta ResponseMetadata, links Links) { a.Meta = meta a.Links = links diff --git a/pkg/candlepin_client/client.go b/pkg/candlepin_client/client.go index 22700ab58..0ccba3948 100644 --- a/pkg/candlepin_client/client.go +++ b/pkg/candlepin_client/client.go @@ -2,8 +2,6 @@ package candlepin_client import ( "context" - "crypto/tls" - "crypto/x509" "fmt" "io" "net/http" @@ -37,40 +35,21 @@ func errorWithResponseBody(message string, httpResp *http.Response, err error) e func getHTTPClient() (http.Client, error) { timeout := 90 * time.Second transport := &http.Transport{ResponseHeaderTimeout: timeout} + var err error certStr := config.Get().Clients.Candlepin.ClientCert keyStr := config.Get().Clients.Candlepin.ClientKey ca := config.Get().Clients.Candlepin.CACert + if certStr != "" { - cert, err := tls.X509KeyPair([]byte(certStr), []byte(keyStr)) + transport, err = config.GetTransport([]byte(certStr), []byte(keyStr), []byte(ca), timeout) if err != nil { - return http.Client{}, fmt.Errorf("could not load cert pair for candlepin %w", err) - } - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, - } - if ca != "" { - pool, err := certPool(ca) - if err != nil { - return http.Client{}, err - } - tlsConfig.RootCAs = pool + return http.Client{}, fmt.Errorf("could not create http transport: %w", err) } - transport.TLSClientConfig = tlsConfig } return http.Client{Transport: transport, Timeout: timeout}, nil } -func certPool(caCert string) (*x509.CertPool, error) { - pool := x509.NewCertPool() - ok := pool.AppendCertsFromPEM([]byte(caCert)) - if !ok { - return nil, fmt.Errorf("could not parse candlepin ca cert") - } - return pool, nil -} - func getCorrelationId(ctx context.Context) string { value := ctx.Value(config.ContextRequestIDKey{}) if value != nil { diff --git a/pkg/config/certificates.go b/pkg/config/certificates.go new file mode 100644 index 000000000..d912156be --- /dev/null +++ b/pkg/config/certificates.go @@ -0,0 +1 @@ +package config diff --git a/pkg/config/config.go b/pkg/config/config.go index 3e72d3d74..03c10e483 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -40,12 +40,13 @@ type Configuration struct { } type Clients struct { - RbacEnabled bool `mapstructure:"rbac_enabled"` - RbacBaseUrl string `mapstructure:"rbac_base_url"` - RbacTimeout int `mapstructure:"rbac_timeout"` - Pulp Pulp `mapstructure:"pulp"` - Redis Redis `mapstructure:"redis"` - Candlepin Candlepin `mapstructure:"candlepin"` + RbacEnabled bool `mapstructure:"rbac_enabled"` + RbacBaseUrl string `mapstructure:"rbac_base_url"` + RbacTimeout int `mapstructure:"rbac_timeout"` + Pulp Pulp `mapstructure:"pulp"` + Redis Redis `mapstructure:"redis"` + Candlepin Candlepin `mapstructure:"candlepin"` + SubsAsFeatures SubsAsFeatures `mapstructure:"subs_as_features"` } type Mocks struct { @@ -96,6 +97,16 @@ type Candlepin struct { DevelOrg bool `mapstructure:"devel_org"` // For use only in dev envs } +type SubsAsFeatures struct { + Server string + ClientCert string `mapstructure:"client_cert"` + ClientKey string `mapstructure:"client_key"` + CACert string `mapstructure:"ca_cert"` + ClientCertPath string `mapstructure:"client_cert_path"` + ClientKeyPath string `mapstructure:"client_key_path"` + CACertPath string `mapstructure:"ca_cert_path"` +} + const RepoClowderBucketName = "content-sources-central-pulp-s3" type ObjectStore struct { @@ -296,6 +307,14 @@ func setDefaults(v *viper.Viper) { v.SetDefault("clients.redis.expiration.pulp_content_path", 1*time.Hour) v.SetDefault("clients.redis.expiration.subscription_check", 1*time.Hour) + v.SetDefault("clients.subs_as_features.server", "") + v.SetDefault("clients.subs_as_features.client_cert", "") + v.SetDefault("clients.subs_as_features.client_key", "") + v.SetDefault("clients.subs_as_features.ca_cert", "") + v.SetDefault("clients.subs_as_features.client_cert_path", "") + v.SetDefault("clients.subs_as_features.client_key_path", "") + v.SetDefault("clients.subs_as_features.ca_cert_path", "") + v.SetDefault("tasking.heartbeat", 1*time.Minute) v.SetDefault("tasking.worker_count", 3) v.SetDefault("tasking.pgx_logging", true) @@ -472,6 +491,40 @@ func ConfigureCertificate() (*tls.Certificate, *string, error) { return &cert, &certString, nil } +func GetTransport(certBytes, keyBytes, caCertBytes []byte, timeout time.Duration) (*http.Transport, error) { + transport := &http.Transport{ResponseHeaderTimeout: timeout} + + if certBytes != nil && keyBytes != nil { + cert, err := tls.X509KeyPair(certBytes, keyBytes) + if err != nil { + return transport, fmt.Errorf("could not load keypair: %w", err) + } + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } + + if caCertBytes != nil { + pool, err := certPool(caCertBytes) + if err != nil { + return transport, err + } + tlsConfig.RootCAs = pool + } + transport.TLSClientConfig = tlsConfig + } + return transport, nil +} + +func certPool(caCert []byte) (*x509.CertPool, error) { + pool := x509.NewCertPool() + ok := pool.AppendCertsFromPEM(caCert) + if !ok { + return nil, fmt.Errorf("could not parse candlepin ca cert") + } + return pool, nil +} + func CDNCertDaysTillExpiration() (int, error) { if Get().Certs.CdnCertPair == nil { return 0, nil diff --git a/pkg/handler/admin_tasks.go b/pkg/handler/admin_tasks.go index b39c5a364..ee85f9dc9 100644 --- a/pkg/handler/admin_tasks.go +++ b/pkg/handler/admin_tasks.go @@ -3,6 +3,7 @@ package handler import ( "net/http" + "github.com/content-services/content-sources-backend/pkg/admin_client" "github.com/content-services/content-sources-backend/pkg/api" "github.com/content-services/content-sources-backend/pkg/dao" ce "github.com/content-services/content-sources-backend/pkg/errors" @@ -13,6 +14,7 @@ import ( type AdminTaskHandler struct { DaoRegistry dao.DaoRegistry + AdminClient admin_client.AdminClient } func checkAccessible(next echo.HandlerFunc) echo.HandlerFunc { @@ -24,19 +26,24 @@ func checkAccessible(next echo.HandlerFunc) echo.HandlerFunc { } } -func RegisterAdminTaskRoutes(engine *echo.Group, daoReg *dao.DaoRegistry) { +func RegisterAdminTaskRoutes(engine *echo.Group, daoReg *dao.DaoRegistry, adminClient *admin_client.AdminClient) { if engine == nil { panic("engine is nil") } if daoReg == nil { panic("taskInfoReg is nil") } + if adminClient == nil { + panic("adminClient is nil") + } adminTaskHandler := AdminTaskHandler{ DaoRegistry: *daoReg, + AdminClient: *adminClient, } addRepoRoute(engine, http.MethodGet, "/admin/tasks/", adminTaskHandler.listTasks, rbac.RbacVerbRead, checkAccessible) addRepoRoute(engine, http.MethodGet, "/admin/tasks/:uuid", adminTaskHandler.fetch, rbac.RbacVerbRead, checkAccessible) + addRepoRoute(engine, http.MethodGet, "/admin/features/", adminTaskHandler.listFeatures, rbac.RbacVerbRead, checkAccessible) } func (adminTaskHandler *AdminTaskHandler) listTasks(c echo.Context) error { @@ -61,6 +68,20 @@ func (adminTaskHandler *AdminTaskHandler) fetch(c echo.Context) error { return c.JSON(http.StatusOK, response) } +func (adminTaskHandler *AdminTaskHandler) listFeatures(c echo.Context) error { + resp, statusCode, err := adminTaskHandler.AdminClient.ListFeatures(c.Request().Context()) + if err != nil { + return ce.NewErrorResponse(statusCode, "Error listing features", err.Error()) + } + + subsAsFeatResp := api.SubsAsFeaturesResponse{} + for _, content := range resp.Content { + subsAsFeatResp.Features = append(subsAsFeatResp.Features, content.Name) + } + + return c.JSON(http.StatusOK, subsAsFeatResp) +} + func ParseAdminTaskFilters(c echo.Context) api.AdminTaskFilterData { filterData := api.AdminTaskFilterData{ AccountId: DefaultAccountId, diff --git a/pkg/handler/admin_tasks_test.go b/pkg/handler/admin_tasks_test.go index b7c6344e0..71d7a44e8 100644 --- a/pkg/handler/admin_tasks_test.go +++ b/pkg/handler/admin_tasks_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "testing" + "github.com/content-services/content-sources-backend/pkg/admin_client" "github.com/content-services/content-sources-backend/pkg/api" "github.com/content-services/content-sources-backend/pkg/config" "github.com/content-services/content-sources-backend/pkg/dao" @@ -87,7 +88,9 @@ func (suite *AdminTasksSuite) serveAdminTasksRouter(req *http.Request, enabled b config.Get().Features.AdminTasks.Accounts = &[]string{seeds.RandomAccountId()} } - RegisterAdminTaskRoutes(pathPrefix, suite.reg.ToDaoRegistry()) + h := AdminTaskHandler{AdminClient: suite.clientMock} + + RegisterAdminTaskRoutes(pathPrefix, suite.reg.ToDaoRegistry(), &h.AdminClient) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) @@ -101,7 +104,8 @@ func (suite *AdminTasksSuite) serveAdminTasksRouter(req *http.Request, enabled b type AdminTasksSuite struct { suite.Suite - reg *dao.MockDaoRegistry + reg *dao.MockDaoRegistry + clientMock *admin_client.MockAdminClient } func TestAdminTasksSuite(t *testing.T) { @@ -109,6 +113,7 @@ func TestAdminTasksSuite(t *testing.T) { } func (suite *AdminTasksSuite) SetupTest() { suite.reg = dao.GetMockDaoRegistry(suite.T()) + suite.clientMock = admin_client.NewMockAdminClient(suite.T()) } func (suite *AdminTasksSuite) TestSimple() { @@ -356,3 +361,23 @@ func (suite *AdminTasksSuite) TestFetchNotAccessible() { assert.Contains(t, string(body), "Neither the user nor account is allowed.") suite.reg.AdminTask.AssertNotCalled(t, "Fetch", task.UUID) } + +func (suite *AdminTasksSuite) TestListFeatures() { + t := suite.T() + + req := httptest.NewRequest(http.MethodGet, api.FullRootPath()+"/admin/features/", nil) + req.Header.Set(api.IdentityHeader, test_handler.EncodedIdentity(t)) + + listFeaturesExpected := admin_client.FeaturesResponse{Content: []admin_client.Content{{Name: "test_feature"}}} + expected := api.SubsAsFeaturesResponse{Features: []string{"test_feature"}} + suite.clientMock.On("ListFeatures", test.MockCtx()).Return(listFeaturesExpected, http.StatusOK, nil) + + code, body, err := suite.serveAdminTasksRouter(req, true, true) + assert.NoError(t, err) + + var response api.SubsAsFeaturesResponse + err = json.Unmarshal(body, &response) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, expected, response) +} diff --git a/pkg/handler/api.go b/pkg/handler/api.go index 9a9d1dc08..06e3362ff 100644 --- a/pkg/handler/api.go +++ b/pkg/handler/api.go @@ -5,6 +5,7 @@ import ( _ "embed" "encoding/json" "fmt" + "github.com/content-services/content-sources-backend/pkg/admin_client" "net/url" "strconv" "strings" @@ -66,6 +67,10 @@ func RegisterRoutes(ctx context.Context, engine *echo.Echo) { } taskClient := client.NewTaskClient(&pgqueue) cpClient := candlepin_client.NewCandlepinClient() + adminClient, err := admin_client.NewAdminClient() + if err != nil { + panic(err) + } ch := cache.Initialize() for i := 0; i < len(paths); i++ { @@ -79,7 +84,7 @@ func RegisterRoutes(ctx context.Context, engine *echo.Echo) { RegisterPopularRepositoriesRoutes(group, daoReg) RegisterTaskInfoRoutes(group, daoReg, &taskClient) RegisterSnapshotRoutes(group, daoReg, &taskClient) - RegisterAdminTaskRoutes(group, daoReg) + RegisterAdminTaskRoutes(group, daoReg, &adminClient) RegisterFeaturesRoutes(group) RegisterPublicRepositoriesRoutes(group, daoReg) RegisterPackageGroupRoutes(group, daoReg)