diff --git a/.vscode/launch.json b/.vscode/launch.json index d98ac8286c..899ef7aa1e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -58,12 +58,7 @@ "args": [ "--config-file", "${workspaceFolder}/cmd/ucpd/ucp-dev.yaml" - ], - "env": { - "BASE_PATH": "/apis/api.ucp.dev/v1alpha3", - "PORT": "9000", - "UCP_CONFIG": "${workspaceFolder}/cmd/ucpd/ucp-self-hosted-dev.yaml" - } + ] }, { "name": "Launch Controller", diff --git a/cmd/applications-rp/cmd/root.go b/cmd/applications-rp/cmd/root.go index 8656dae6bc..7df3495907 100644 --- a/cmd/applications-rp/cmd/root.go +++ b/cmd/applications-rp/cmd/root.go @@ -111,7 +111,12 @@ var rootCmd = &cobra.Command{ Services: hostingSvc, } + // Make the logger available to the services. ctx := logr.NewContext(context.Background(), logger) + + // Make the hosting configuration available to the services. + ctx = hostoptions.WithContext(ctx, options.Config) + return hosting.RunWithInterrupts(ctx, host) }, } diff --git a/cmd/applications-rp/radius-dev.yaml b/cmd/applications-rp/radius-dev.yaml index c13ad6ada4..c69667a7dc 100644 --- a/cmd/applications-rp/radius-dev.yaml +++ b/cmd/applications-rp/radius-dev.yaml @@ -2,7 +2,7 @@ environment: name: Dev roleLocation: "global" -storageProvider: +databaseProvider: provider: "etcd" etcd: inmemory: true diff --git a/cmd/applications-rp/radius-self-hosted.yaml b/cmd/applications-rp/radius-self-hosted.yaml index 2b9dd9368d..121ee728d0 100644 --- a/cmd/applications-rp/radius-self-hosted.yaml +++ b/cmd/applications-rp/radius-self-hosted.yaml @@ -8,9 +8,9 @@ # - Disables metrics and profiler # environment: - name: Dev + name: self-hosted roleLocation: "global" -storageProvider: +databaseProvider: provider: "apiserver" apiserver: context: '' diff --git a/cmd/dynamic-rp/dynamicrp-dev.yaml b/cmd/dynamic-rp/dynamicrp-dev.yaml index 4794a02ce0..22f60a4fe7 100644 --- a/cmd/dynamic-rp/dynamicrp-dev.yaml +++ b/cmd/dynamic-rp/dynamicrp-dev.yaml @@ -2,7 +2,7 @@ environment: name: Dev roleLocation: "global" -storageProvider: +databaseProvider: provider: "apiserver" apiserver: context: '' diff --git a/cmd/ucpd/cmd/root.go b/cmd/ucpd/cmd/root.go index 4c012bd07e..d99e885800 100644 --- a/cmd/ucpd/cmd/root.go +++ b/cmd/ucpd/cmd/root.go @@ -19,6 +19,7 @@ package cmd import ( "context" "fmt" + "os" "github.com/go-logr/logr" "github.com/spf13/cobra" @@ -27,6 +28,7 @@ import ( "github.com/radius-project/radius/pkg/armrpc/hostoptions" "github.com/radius-project/radius/pkg/components/database/databaseprovider" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/hosting" "github.com/radius-project/radius/pkg/ucp/server" "github.com/radius-project/radius/pkg/ucp/ucplog" @@ -38,12 +40,23 @@ var rootCmd = &cobra.Command{ Long: `Server process for the Universal Control Plane (UCP).`, RunE: func(cmd *cobra.Command, args []string) error { configFilePath := cmd.Flag("config-file").Value.String() - options, err := server.NewServerOptionsFromEnvironment(configFilePath) + + bs, err := os.ReadFile(configFilePath) if err != nil { - return err + return fmt.Errorf("failed to read configuration file: %w", err) + } + + config, err := ucp.LoadConfig(bs) + if err != nil { + return fmt.Errorf("failed to parse configuration file: %w", err) + } + + options, err := ucp.NewOptions(cmd.Context(), config) + if err != nil { + return fmt.Errorf("failed to create server options: %w", err) } - logger, flush, err := ucplog.NewLogger(ucplog.LoggerName, &options.LoggingOptions) + logger, flush, err := ucplog.NewLogger(ucplog.LoggerName, &options.Config.Logging) if err != nil { return err } @@ -52,17 +65,17 @@ var rootCmd = &cobra.Command{ // Must set the logger before using controller-runtime. runtimelog.SetLogger(logger) - if options.DatabaseProviderOptions.Provider == databaseprovider.TypeETCD && - options.DatabaseProviderOptions.ETCD.InMemory { + if options.Config.Database.Provider == databaseprovider.TypeETCD && + options.Config.Database.ETCD.InMemory { // For in-memory etcd we need to register another service to manage its lifecycle. // // The client will be initialized asynchronously. clientconfigSource := hosting.NewAsyncValue[etcdclient.Client]() - options.DatabaseProviderOptions.ETCD.Client = clientconfigSource - options.SecretProviderOptions.ETCD.Client = clientconfigSource + options.Config.Database.ETCD.Client = clientconfigSource + options.Config.Secrets.ETCD.Client = clientconfigSource } - host, err := server.NewServer(&options) + host, err := server.NewServer(options) if err != nil { return err } diff --git a/cmd/ucpd/ucp-dev.yaml b/cmd/ucpd/ucp-dev.yaml index 32f1a465cf..a7561eeb7a 100644 --- a/cmd/ucpd/ucp-dev.yaml +++ b/cmd/ucpd/ucp-dev.yaml @@ -9,8 +9,14 @@ # - Talk to Portable Resources' Providers on port 8081 # - Disables metrics and profiler # -location: 'global' -storageProvider: +environment: + name: Dev + roleLocation: "global" +server: + port: 9000 + pathBase: /apis/api.ucp.dev/v1alpha3 + +databaseProvider: provider: "apiserver" apiserver: context: '' @@ -32,19 +38,20 @@ profilerProvider: #Default planes configuration with which ucp starts # TODO: Remove azure and aws planes once rad provider commands are supported -planes: - - id: "/planes/aws/aws" - properties: - kind: "AWS" - - id: "/planes/radius/local" - properties: - resourceProviders: - Applications.Core: "http://localhost:8080" - Applications.Messaging: "http://localhost:8080" - Applications.Dapr: "http://localhost:8080" - Applications.Datastores: "http://localhost:8080" - Microsoft.Resources: "http://localhost:5017" - kind: "UCPNative" +initialization: + planes: + - id: "/planes/aws/aws" + properties: + kind: "AWS" + - id: "/planes/radius/local" + properties: + resourceProviders: + Applications.Core: "http://localhost:8080" + Applications.Messaging: "http://localhost:8080" + Applications.Dapr: "http://localhost:8080" + Applications.Datastores: "http://localhost:8080" + Microsoft.Resources: "http://localhost:5017" + kind: "UCPNative" identity: authMethod: default @@ -76,4 +83,4 @@ logging: tracerProvider: serviceName: "ucp" zipkin: - url: "http://localhost:9411/api/v2/spans" + url: "http://localhost:9411/api/v2/spans" \ No newline at end of file diff --git a/deploy/Chart/templates/controller/configmaps.yaml b/deploy/Chart/templates/controller/configmaps.yaml index 314213647b..17fabbc717 100644 --- a/deploy/Chart/templates/controller/configmaps.yaml +++ b/deploy/Chart/templates/controller/configmaps.yaml @@ -12,7 +12,7 @@ data: host: "0.0.0.0" port: 9443 - storageProvider: + databaseProvider: provider: "apiserver" apiserver: context: "" diff --git a/deploy/Chart/templates/dynamic-rp/configmaps.yaml b/deploy/Chart/templates/dynamic-rp/configmaps.yaml index 31368b5db0..9dc3c2ddf2 100644 --- a/deploy/Chart/templates/dynamic-rp/configmaps.yaml +++ b/deploy/Chart/templates/dynamic-rp/configmaps.yaml @@ -13,7 +13,7 @@ data: environment: name: self-hosted roleLocation: "global" - storageProvider: + databaseProvider: provider: "apiserver" apiserver: context: "" diff --git a/deploy/Chart/templates/rp/configmaps.yaml b/deploy/Chart/templates/rp/configmaps.yaml index e444f31999..6429960c08 100644 --- a/deploy/Chart/templates/rp/configmaps.yaml +++ b/deploy/Chart/templates/rp/configmaps.yaml @@ -13,7 +13,7 @@ data: environment: name: self-hosted roleLocation: "global" - storageProvider: + databaseProvider: provider: "apiserver" apiserver: context: "" diff --git a/deploy/Chart/templates/ucp/configmaps.yaml b/deploy/Chart/templates/ucp/configmaps.yaml index d56d0f4f30..cc9968228c 100644 --- a/deploy/Chart/templates/ucp/configmaps.yaml +++ b/deploy/Chart/templates/ucp/configmaps.yaml @@ -10,8 +10,14 @@ data: ucp-config.yaml: |- # Radius configuration file. # See https://github.com/radius-project/radius/blob/main/docs/contributing/contributing-code/contributing-code-control-plane/configSettings.md for more information. - location: 'global' - storageProvider: + environment: + name: Dev + roleLocation: "global" + server: + port: 9443 + pathBase: /apis/api.ucp.dev/v1alpha3 + tlsCertificateDirectory: /var/tls/cert + databaseProvider: provider: "apiserver" apiserver: context: "" @@ -30,20 +36,20 @@ data: profilerProvider: enabled: true port: 6060 - - planes: - - id: "/planes/radius/local" - properties: - resourceProviders: - Applications.Core: "http://applications-rp.radius-system:5443" - Applications.Dapr: "http://applications-rp.radius-system:5443" - Applications.Datastores: "http://applications-rp.radius-system:5443" - Applications.Messaging: "http://applications-rp.radius-system:5443" - Microsoft.Resources: "http://bicep-de.radius-system:6443" - kind: "UCPNative" - - id: "/planes/aws/aws" - properties: - kind: "AWS" + initialization: + planes: + - id: "/planes/radius/local" + properties: + resourceProviders: + Applications.Core: "http://applications-rp.radius-system:5443" + Applications.Dapr: "http://applications-rp.radius-system:5443" + Applications.Datastores: "http://applications-rp.radius-system:5443" + Applications.Messaging: "http://applications-rp.radius-system:5443" + Microsoft.Resources: "http://bicep-de.radius-system:6443" + kind: "UCPNative" + - id: "/planes/aws/aws" + properties: + kind: "AWS" identity: authMethod: UCPCredential diff --git a/deploy/Chart/templates/ucp/deployment.yaml b/deploy/Chart/templates/ucp/deployment.yaml index 776b01ac7b..9c79557899 100644 --- a/deploy/Chart/templates/ucp/deployment.yaml +++ b/deploy/Chart/templates/ucp/deployment.yaml @@ -36,8 +36,6 @@ spec: args: - --config-file=/etc/config/ucp-config.yaml env: - - name: BASE_PATH - value: '/apis/api.ucp.dev/v1alpha3' # listen for APIService URLs - name: TLS_CERT_DIR value: '/var/tls/cert' - name: PORT diff --git a/docs/contributing/contributing-code/contributing-code-control-plane/configSettings.md b/docs/contributing/contributing-code/contributing-code-control-plane/configSettings.md index e54edf127a..356140ac77 100644 --- a/docs/contributing/contributing-code/contributing-code-control-plane/configSettings.md +++ b/docs/contributing/contributing-code/contributing-code-control-plane/configSettings.md @@ -20,7 +20,7 @@ The following properties can be specified in configuration for all services: |-----|-------------|---------| | environment | Environment name and its role location | [**See below**](#environment) | | identity | AAD APP authentication for the resource provider | [**See below**](#identity) | -| storageProvider | Configuration options for the data storage provider | [**See below**](#storageprovider) | +| databaseProvider | Configuration options for the database provider | [**See below**](#databaseprovider) | | queueProvider | Configuration options for the provider to create and manage the queue client | [**See below**](#queueprovider) | | secretProvider | Configuration options for the provider to manage credential | [**See below**](#secretprovider) | | server | Configuration options for the HTTP server bootstrap | [**See below**](#server) | @@ -61,18 +61,18 @@ The following are properties that can be specified for UCP: | audience | The recipient of the certificate | `https://management.core.windows.net` | | pemCertPath | Path to certificate file | `/var/certs/rp-aad-app.pem` | -### storageProvider +### databaseProvider | Key | Description | Example | |-----|-------------|---------| -| provider | The type of storage provider | `apiServer` | -| apiServer | Object containing properties for Kubernetes APIServer store | [**See below**](#apiserver) | -| etcd | Object containing properties for ETCD store | [**See below**](#etcd)| +| provider | The type of database provider | `apiServer` | +| apiServer | Object containing properties for Kubernetes APIServer database | [**See below**](#apiserver) | +| etcd | Object containing properties for ETCD database | [**See below**](#etcd)| ### queueProvider | Key | Description | Example | |-----|-------------|---------| | provider | The type of queue provider | `apiServer` | -| apiServer | Object containing properties for Kubernetes APIServer store | [**See below**](#apiserver) | +| apiServer | Object containing properties for Kubernetes APIServer queue | [**See below**](#apiserver) | | inMemoryQueue | Object containing properties for InMemory Queue client | | ### secretProvider @@ -181,7 +181,7 @@ Below are completed examples of possible configurations: environment: name: self-hosted roleLocation: "global" -storageProvider: +databaseProvider: provider: "apiserver" apiserver: context: "" @@ -210,7 +210,7 @@ ucp: ### UCP ```yaml location: 'global' -storageProvider: +databaseProvider: provider: "apiserver" apiserver: context: "" diff --git a/pkg/armrpc/asyncoperation/worker/service.go b/pkg/armrpc/asyncoperation/worker/service.go index 305a362060..f4eaefa79f 100644 --- a/pkg/armrpc/asyncoperation/worker/service.go +++ b/pkg/armrpc/asyncoperation/worker/service.go @@ -18,64 +18,56 @@ package worker import ( "context" + "sync" manager "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" - "github.com/radius-project/radius/pkg/armrpc/hostoptions" - "github.com/radius-project/radius/pkg/components/database/databaseprovider" + "github.com/radius-project/radius/pkg/components/database" "github.com/radius-project/radius/pkg/components/queue" - "github.com/radius-project/radius/pkg/components/queue/queueprovider" "github.com/radius-project/radius/pkg/ucp/ucplog" ) // Service is the base worker service implementation to initialize and start worker. +// All exported fields should be initialized by the caller. type Service struct { - // ProviderName is the name of provider namespace. - ProviderName string - // Options is the server hosting options. - Options hostoptions.HostOptions - // DatabaseProvider is the provider of the database client. - DatabaseProvider *databaseprovider.DatabaseProvider + // DatabaseClient is database client. + DatabaseClient database.Client + // OperationStatusManager is the manager of the operation status. OperationStatusManager manager.StatusManager - // Controllers is the registry of the async operation controllers. - Controllers *ControllerRegistry - // RequestQueue is the queue client for async operation request message. - RequestQueue queue.Client -} -// Init initializes worker service - it initializes the StorageProvider, RequestQueue, OperationStatusManager, Controllers, KubeClient and -// returns an error if any of these operations fail. -func (s *Service) Init(ctx context.Context) error { - s.DatabaseProvider = databaseprovider.FromOptions(s.Options.Config.DatabaseProvider) - qp := queueprovider.New(s.Options.Config.QueueProvider) + // Options configures options for the async worker. + Options Options - var err error - storageClient, err := s.DatabaseProvider.GetClient(ctx) - if err != nil { - return err - } + // QueueProvider is the queue client. + QueueClient queue.Client - s.RequestQueue, err = qp.GetClient(ctx) - if err != nil { - return err - } + // controllers is the registry of the async operation controllers. + controllers *ControllerRegistry - s.OperationStatusManager = manager.New(storageClient, s.RequestQueue, s.Options.Config.Env.RoleLocation) - s.Controllers = NewControllerRegistry() - return nil + // controllersInit is used to ensure single initialization of controllers. + controllersInit sync.Once +} + +// Controllers returns the controller registry for the worker service. +func (s *Service) Controllers() *ControllerRegistry { + s.controllersInit.Do(func() { + s.controllers = NewControllerRegistry() + }) + + return s.controllers } // Start creates and starts a worker, and logs any errors that occur while starting the worker. -func (s *Service) Start(ctx context.Context, opt Options) error { +func (s *Service) Start(ctx context.Context) error { logger := ucplog.FromContextOrDiscard(ctx) - ctx = hostoptions.WithContext(ctx, s.Options.Config) // Create and start worker. - worker := New(opt, s.OperationStatusManager, s.RequestQueue, s.Controllers) + worker := New(s.Options, s.OperationStatusManager, s.QueueClient, s.Controllers()) logger.Info("Start Worker...") if err := worker.Start(ctx); err != nil { logger.Error(err, "failed to start worker...") + return err } logger.Info("Worker stopped...") diff --git a/pkg/armrpc/hostoptions/providerconfig.go b/pkg/armrpc/hostoptions/providerconfig.go index 59fd7a7589..e8f19f1fb0 100644 --- a/pkg/armrpc/hostoptions/providerconfig.go +++ b/pkg/armrpc/hostoptions/providerconfig.go @@ -17,9 +17,12 @@ limitations under the License. package hostoptions import ( + "fmt" + "github.com/radius-project/radius/pkg/components/database/databaseprovider" "github.com/radius-project/radius/pkg/components/queue/queueprovider" "github.com/radius-project/radius/pkg/components/secret/secretprovider" + metricsprovider "github.com/radius-project/radius/pkg/metrics/provider" profilerprovider "github.com/radius-project/radius/pkg/profiler/provider" "github.com/radius-project/radius/pkg/trace" @@ -31,7 +34,7 @@ import ( type ProviderConfig struct { Env EnvironmentOptions `yaml:"environment"` Identity IdentityOptions `yaml:"identity"` - DatabaseProvider databaseprovider.Options `yaml:"storageProvider"` + DatabaseProvider databaseprovider.Options `yaml:"databaseProvider"` SecretProvider secretprovider.SecretProviderOptions `yaml:"secretProvider"` QueueProvider queueprovider.QueueProviderOptions `yaml:"queueProvider"` Server *ServerOptions `yaml:"server,omitempty"` @@ -59,6 +62,18 @@ type ServerOptions struct { ArmMetadataEndpoint string `yaml:"armMetadataEndpoint,omitempty"` // EnableAuth when set the arm client authetication will be performed EnableArmAuth bool `yaml:"enableArmAuth,omitempty"` + + // TLSCertificateDirectory is the directory where the TLS certificates are stored. + // + // The server code will expect to find the following files in this directory: + // - tls.crt: The server's certificate. + // - tls.key: The server's private key. + TLSCertificateDirectory string `yaml:"tlsCertificateDirectory,omitempty"` +} + +// Address returns the address of the server in host:port format. +func (s ServerOptions) Address() string { + return s.Host + ":" + fmt.Sprint(s.Port) } // WorkerServerOptions includes the worker server options. diff --git a/pkg/components/testhost/clients.go b/pkg/components/testhost/clients.go new file mode 100644 index 0000000000..3f534c6d96 --- /dev/null +++ b/pkg/components/testhost/clients.go @@ -0,0 +1,274 @@ +/* +Copyright 2023 The Radius Authors. + +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 testhost + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "testing" + "time" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/armrpc/rpctest" + "github.com/stretchr/testify/require" +) + +// TestResponse is returned from requests made against a TestHost. Tests should use the functions defined +// on TestResponse for validation. +type TestResponse struct { + // Raw is the raw HTTP response. + Raw *http.Response + + // Body is the response body. + Body *bytes.Buffer + + // Error is the ARM error response if the response status code is >= 400. + Error *v1.ErrorResponse + + // t is the test object. + t *testing.T + + // host is the TestHost that served this response. + host *TestHost +} + +// MakeFixtureRequest sends a request to the server using a file on disk as the payload (body). Use the fixture +// parameter to specify the path to a file. +func (th *TestHost) MakeFixtureRequest(method string, pathAndQuery string, fixture string) *TestResponse { + body, err := os.ReadFile(fixture) + require.NoError(th.t, err, "reading fixture failed") + return th.MakeRequest(method, pathAndQuery, body) +} + +// MakeTypedRequest sends a request to the server by marshalling the provided object to JSON. +func (th *TestHost) MakeTypedRequest(method string, pathAndQuery string, body any) *TestResponse { + if body == nil { + return th.MakeRequest(method, pathAndQuery, nil) + } + + b, err := json.Marshal(body) + require.NoError(th.t, err, "marshalling body failed") + return th.MakeRequest(method, pathAndQuery, b) +} + +// MakeRequest sends a request to the server. +func (th *TestHost) MakeRequest(method string, pathAndQuery string, body []byte) *TestResponse { + // Prepend the base path if this is a relative URL. + requestUrl := pathAndQuery + parsed, err := url.Parse(pathAndQuery) + require.NoError(th.t, err, "parsing URL failed") + if !parsed.IsAbs() { + requestUrl = th.BaseURL() + pathAndQuery + } + + client := th.Client() + request, err := rpctest.NewHTTPRequestWithContent(context.Background(), method, requestUrl, body) + require.NoError(th.t, err, "creating request failed") + + ctx := rpctest.NewARMRequestContext(request) + request = request.WithContext(ctx) + + response, err := client.Do(request) + require.NoError(th.t, err, "sending request failed") + + // Buffer the response so we can read multiple times. + responseBuffer := &bytes.Buffer{} + _, err = io.Copy(responseBuffer, response.Body) + response.Body.Close() + require.NoError(th.t, err, "copying response failed") + + response.Body = io.NopCloser(responseBuffer) + + // Pretty-print response for logs. + if len(responseBuffer.Bytes()) > 0 { + var data any + err = json.Unmarshal(responseBuffer.Bytes(), &data) + require.NoError(th.t, err, "unmarshalling response failed") + + text, err := json.MarshalIndent(&data, "", " ") + require.NoError(th.t, err, "marshalling response failed") + th.t.Log("Response Body: \n" + string(text)) + } + + var errorResponse *v1.ErrorResponse + if response.StatusCode >= 400 { + // The response MUST be an arm error for a non-success status code. + errorResponse = &v1.ErrorResponse{} + err := json.Unmarshal(responseBuffer.Bytes(), &errorResponse) + require.NoError(th.t, err, "unmarshalling error response failed - THIS IS A SERIOUS BUG. ALL ERROR RESPONSES MUST USE THE STANDARD FORMAT") + } + + return &TestResponse{Raw: response, Body: responseBuffer, Error: errorResponse, host: th, t: th.t} +} + +// EqualsErrorCode compares a TestResponse against an expected status code and error code. EqualsErrorCode assumes the response +// uses the ARM error format (required for our APIs). +func (tr *TestResponse) EqualsErrorCode(statusCode int, code string) { + require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") + require.NotNil(tr.t, tr.Error, "expected an error but actual response did not contain one") + require.Equal(tr.t, code, tr.Error.Error.Code, "actual error code was different from expected") +} + +// EqualsFixture compares a TestResponse against an expected status code and body payload. Use the fixture parameter to specify +// the path to a file. +func (tr *TestResponse) EqualsFixture(statusCode int, fixture string) { + body, err := os.ReadFile(fixture) + require.NoError(tr.t, err, "reading fixture failed") + tr.EqualsResponse(statusCode, body) +} + +// EqualsStatusCode compares a TestResponse against an expected status code (ingnores the body payload). +func (tr *TestResponse) EqualsStatusCode(statusCode int) { + require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") +} + +// EqualsFixture compares a TestResponse against an expected status code and body payload. +func (tr *TestResponse) EqualsResponse(statusCode int, body []byte) { + if len(body) == 0 { + require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") + require.Empty(tr.t, tr.Body.Bytes(), "expected an empty response but actual response had a body") + return + } + + var expected map[string]any + err := json.Unmarshal(body, &expected) + require.NoError(tr.t, err, "unmarshalling expected response failed") + + var actual map[string]any + err = json.Unmarshal(tr.Body.Bytes(), &actual) + + tr.removeSystemData(actual) + + require.NoError(tr.t, err, "unmarshalling actual response failed. Got '%v'", tr.Body.String()) + require.EqualValues(tr.t, expected, actual, "response body did not match expected") + require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") +} + +// EqualsValue compares a TestResponse against an expected status code and an response body. +// +// If the systemData propert is present in the response, it will be removed. +func (tr *TestResponse) EqualsValue(statusCode int, expected any) { + var actual map[string]any + err := json.Unmarshal(tr.Body.Bytes(), &actual) + require.NoError(tr.t, err, "unmarshalling actual response failed") + + // Convert expected input to map[string]any to compare with actual response. + expectedBytes, err := json.Marshal(expected) + require.NoError(tr.t, err, "marshalling expected response failed") + + var expectedMap map[string]any + err = json.Unmarshal(expectedBytes, &expectedMap) + require.NoError(tr.t, err, "unmarshalling expected response failed") + + tr.removeSystemData(expectedMap) + tr.removeSystemData(actual) + + require.EqualValues(tr.t, expectedMap, actual, "response body did not match expected") + require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") +} + +// EqualsEmptyList compares a TestResponse against an expected status code and an empty resource list. +func (tr *TestResponse) EqualsEmptyList() { + expected := map[string]any{ + "value": []any{}, + } + + var actual map[string]any + err := json.Unmarshal(tr.Body.Bytes(), &actual) + + tr.removeSystemData(actual) + + require.NoError(tr.t, err, "unmarshalling actual response failed") + require.EqualValues(tr.t, expected, actual, "response body did not match expected") + require.Equal(tr.t, http.StatusOK, tr.Raw.StatusCode, "status code did not match expected") +} + +func (tr *TestResponse) ReadAs(obj any) { + tr.t.Helper() + + decoder := json.NewDecoder(tr.Body) + decoder.DisallowUnknownFields() + + err := decoder.Decode(obj) + require.NoError(tr.t, err, "unmarshalling expected response failed") +} + +func (tr *TestResponse) WaitForOperationComplete(timeout *time.Duration) *TestResponse { + if tr.Raw.StatusCode != http.StatusCreated && tr.Raw.StatusCode != http.StatusAccepted { + // Response is already terminal. + return tr + } + + if timeout == nil { + x := 30 * time.Second + timeout = &x + } + + timer := time.After(*timeout) + poller := time.NewTicker(1 * time.Second) + defer poller.Stop() + for { + select { + case <-timer: + tr.t.Fatalf("timed out waiting for operation to complete") + return nil // unreachable + case <-poller.C: + // The Location header should give us the operation status URL. + response := tr.host.MakeRequest(http.MethodGet, tr.Raw.Header.Get("Azure-AsyncOperation"), nil) + + // To determine if the response is terminal we need to read the provisioning state field. + operationStatus := v1.AsyncOperationStatus{} + response.ReadAs(&operationStatus) + if operationStatus.Status.IsTerminal() { + // Response is terminal. + return response + } + + continue + } + } +} + +func (tr *TestResponse) removeSystemData(responseBody map[string]any) { + // Delete systemData property if found, it's not stable so we don't include it in baselines. + _, ok := responseBody["systemData"] + if ok { + delete(responseBody, "systemData") + return + } + + value, ok := responseBody["value"] + if !ok { + return + } + + valueSlice, ok := value.([]any) + if !ok { + return + } + + for _, v := range valueSlice { + if vMap, ok := v.(map[string]any); ok { + tr.removeSystemData(vMap) + } + } +} diff --git a/pkg/components/testhost/doc.go b/pkg/components/testhost/doc.go new file mode 100644 index 0000000000..0d187588b9 --- /dev/null +++ b/pkg/components/testhost/doc.go @@ -0,0 +1,23 @@ +/* +Copyright 2023 The Radius Authors. + +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 testhost provides a host for running any Radius control-plane component +// as an in-memory server for testing purposes. +// +// This package should be wrapped in a test package specific to the component under test. +// The wrapping design allows for component-specific depenendendencies to be defined without +// polluting the shared code. +package testhost diff --git a/pkg/components/testhost/host.go b/pkg/components/testhost/host.go new file mode 100644 index 0000000000..83f5f19cc9 --- /dev/null +++ b/pkg/components/testhost/host.go @@ -0,0 +1,148 @@ +/* +Copyright 2023 The Radius Authors. + +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 testhost provides a host for running any Radius control-plane component +// as an in-memory server for testing purposes. +// +// This package should be wrapped in a test package specific to the component under test. +// The wrapping design allows for component-specific depenendendencies to be defined without +// polluting the shared code. +package testhost + +import ( + "context" + "net" + "net/http" + "net/url" + "sync" + "testing" + "time" + + "github.com/radius-project/radius/pkg/ucp/hosting" + "github.com/radius-project/radius/test/testcontext" + "github.com/stretchr/testify/require" +) + +// StartHost starts a new test host for the given hosting.Host and returns a TestHost instance. +// The TestHost will have its lifecycle managed by the test context, and will be shut down when the test +// completes. +func StartHost(t *testing.T, host *hosting.Host, baseURL string) *TestHost { + ctx, cancel := context.WithCancel(testcontext.New(t)) + errs, messages := host.RunAsync(ctx) + + go func() { + for msg := range messages { + t.Logf("Message: %s", msg) + } + }() + + th := &TestHost{ + baseURL: baseURL, + host: host, + messages: messages, + cancel: cancel, + stoppedChan: errs, + t: t, + } + t.Cleanup(th.Close) + + // Wait for the server to start listening on the port. + require.Eventuallyf(t, func() bool { + u, err := url.Parse(baseURL) + if err != nil { + panic("Invalid URL: " + baseURL) + } + + conn, err := net.Dial("tcp", net.JoinHostPort(u.Hostname(), u.Port())) + if err != nil { + t.Logf("Waiting for server to start listening on port: %v", err) + return false + } + defer conn.Close() + + return true + }, time.Second*5, time.Millisecond*20, "server did not start listening on port") + + return th +} + +// TestHost is a test server for any Radius control-plane component. Do not construct this type directly, use the Start function. +type TestHost struct { + // baseURL is the base URL of the server, including the path base. + baseURL string + + // host is the hosting process running the component. + host *hosting.Host + + // messages is the channel that will receive lifecycle messages from the host. + messages <-chan hosting.LifecycleMessage + + // cancel is the function to call to stop the server. + cancel context.CancelFunc + + // stoppedChan is the channel that will be closed when the server has stopped. + stoppedChan <-chan error + + // shutdown is used to ensure that Close is only called once. + shutdown sync.Once + + // t is the testing.T instance to use for assertions. + t *testing.T +} + +// Close shuts down the server and will block until shutdown completes. +func (th *TestHost) Close() { + // We're being picking about resource cleanup here, because unless we are picky we hit scalability + // problems in tests pretty quickly. + th.shutdown.Do(func() { + // Shut down the host. + th.cancel() + + if th.stoppedChan != nil { + <-th.stoppedChan // host stopped + } + }) +} + +// BaseURL returns the base URL of the server, including the path base. +// +// This should be used as a URL prefix for all requests to the server. +func (th *TestHost) BaseURL() string { + return th.baseURL +} + +// Client returns the HTTP client to use to make requests to the server. +func (th *TestHost) Client() *http.Client { + return http.DefaultClient +} + +// T returns the testing.T instance associated with the test host. +func (th *TestHost) T() *testing.T { + return th.t +} + +// AllocateFreePort chooses a random port for use in tests. +func AllocateFreePort(t *testing.T) int { + listener, err := net.Listen("tcp", ":0") + require.NoError(t, err, "failed to allocate port") + + port := listener.Addr().(*net.TCPAddr).Port + + err = listener.Close() + require.NoError(t, err, "failed to close listener") + + return port +} diff --git a/pkg/dynamicrp/backend/service.go b/pkg/dynamicrp/backend/service.go index bbbd33955f..710a0fccf0 100644 --- a/pkg/dynamicrp/backend/service.go +++ b/pkg/dynamicrp/backend/service.go @@ -18,17 +18,17 @@ package backend import ( "context" - "fmt" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/worker" - "github.com/radius-project/radius/pkg/armrpc/hostoptions" "github.com/radius-project/radius/pkg/dynamicrp" + "github.com/radius-project/radius/pkg/recipes/controllerconfig" ) // Service runs the backend for the dynamic-rp. type Service struct { worker.Service options *dynamicrp.Options + recipes *controllerconfig.RecipeControllerConfig } // NewService creates a new service to run the dynamic-rp backend. @@ -36,38 +36,49 @@ func NewService(options *dynamicrp.Options) *Service { return &Service{ options: options, Service: worker.Service{ - ProviderName: "dynamic-rp", - Options: hostoptions.HostOptions{ - Config: &hostoptions.ProviderConfig{ - Env: options.Config.Environment, - DatabaseProvider: options.Config.Database, - SecretProvider: options.Config.Secrets, - QueueProvider: options.Config.Queue, - }, - }, + // Will be initialized later }, + recipes: options.Recipes, } } // Name returns the name of the service used for logging. func (w *Service) Name() string { - return fmt.Sprintf("%s async worker", w.Service.ProviderName) + return "dynamic-rp async worker" } // Run runs the service. func (w *Service) Run(ctx context.Context) error { - err := w.Init(ctx) + if w.options.Config.Worker.MaxOperationConcurrency != nil { + w.Service.Options.MaxOperationConcurrency = *w.options.Config.Worker.MaxOperationConcurrency + } + if w.options.Config.Worker.MaxOperationRetryCount != nil { + w.Service.Options.MaxOperationRetryCount = *w.options.Config.Worker.MaxOperationRetryCount + } + + databaseClient, err := w.options.DatabaseProvider.GetClient(ctx) if err != nil { return err } - workerOptions := worker.Options{} - if w.options.Config.Worker.MaxOperationConcurrency != nil { - workerOptions.MaxOperationConcurrency = *w.options.Config.Worker.MaxOperationConcurrency + queueClient, err := w.options.QueueProvider.GetClient(ctx) + if err != nil { + return err } - if w.options.Config.Worker.MaxOperationRetryCount != nil { - workerOptions.MaxOperationRetryCount = *w.options.Config.Worker.MaxOperationRetryCount + + w.Service.DatabaseClient = databaseClient + w.Service.QueueClient = queueClient + w.Service.OperationStatusManager = w.options.StatusManager + + err = w.registerControllers(ctx) + if err != nil { + return err } - return w.Start(ctx, workerOptions) + return w.Start(ctx) +} + +func (w *Service) registerControllers(ctx context.Context) error { + // No controllers yet. + return nil } diff --git a/pkg/dynamicrp/config.go b/pkg/dynamicrp/config.go index 7cda77cf6e..1ffa71bd47 100644 --- a/pkg/dynamicrp/config.go +++ b/pkg/dynamicrp/config.go @@ -32,12 +32,14 @@ import ( ) // Config defines the configuration for the DynamicRP server. +// +// For testability, all fields on this struct MUST be parsable from YAML without any further initialization required. type Config struct { // Bicep configures properties for the Bicep recipe driver. Bicep hostoptions.BicepOptions `yaml:"bicep"` // Database is the configuration for the database. - Database databaseprovider.Options `yaml:"storageProvider"` + Database databaseprovider.Options `yaml:"databaseProvider"` // Environment is the configuration for the hosting environment. Environment hostoptions.EnvironmentOptions `yaml:"environment"` diff --git a/pkg/dynamicrp/integrationtest/dynamic/providers_test.go b/pkg/dynamicrp/integrationtest/dynamic/providers_test.go new file mode 100644 index 0000000000..bcfda112ff --- /dev/null +++ b/pkg/dynamicrp/integrationtest/dynamic/providers_test.go @@ -0,0 +1,174 @@ +/* +Copyright 2023 The Radius Authors. + +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 dynamic + +import ( + "context" + "net/http" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/dynamicrp/testhost" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + ucptesthost "github.com/radius-project/radius/pkg/ucp/testhost" + "github.com/stretchr/testify/require" +) + +const ( + radiusPlaneName = "testing" + resourceProviderNamespace = "Applications.Test" + resourceTypeName = "exampleResources" + locationName = v1.LocationGlobal + apiVersion = "2024-01-01" + + resourceGroupName = "test-group" + exampleResourceName = "my-example" + + exampleResourcePlaneID = "/planes/radius/" + radiusPlaneName + exampleResourceGroupID = exampleResourcePlaneID + "/resourceGroups/test-group" + + exampleResourceID = exampleResourceGroupID + "/providers/Applications.Test/exampleResources/" + exampleResourceName + exampleResourceURL = exampleResourceID + "?api-version=" + apiVersion +) + +// This test covers the lifecycle of a dynamic resource. +func Test_Dynamic_Resource_Lifecycle(t *testing.T) { + _, ucp := testhost.Start(t) + + // Setup a resource provider (Applications.Test/exampleResources) + createRadiusPlane(ucp) + createResourceProvider(ucp) + createResourceType(ucp) + createAPIVersion(ucp) + createLocation(ucp) + + // Setup a resource group where we can interact with the new resource type. + createResourceGroup(ucp) + + // We have not yet implemented any functionality for dynamic RP. + // + // This is the hello-worldiest of tests. We're just making sure that all + // of the infrastructure works. + response := ucp.MakeRequest(http.MethodGet, exampleResourceURL, nil) + response.EqualsErrorCode(404, "NotFound") +} + +func createRadiusPlane(server *ucptesthost.TestHost) v20231001preview.RadiusPlanesClientCreateOrUpdateResponse { + ctx := context.Background() + + plane := v20231001preview.RadiusPlaneResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.RadiusPlaneResourceProperties{ + // Note: this is a workaround. Properties is marked as a required field in + // the API. Without passing *something* here the body will be rejected. + ProvisioningState: to.Ptr(v20231001preview.ProvisioningStateSucceeded), + ResourceProviders: map[string]*string{}, + }, + } + + client := server.UCP().NewRadiusPlanesClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, plane, nil) + require.NoError(server.T(), err) + + response, err := poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) + + return response +} + +func createResourceProvider(server *ucptesthost.TestHost) { + ctx := context.Background() + + resourceProvider := v20231001preview.ResourceProviderResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.ResourceProviderProperties{}, + } + + client := server.UCP().NewResourceProvidersClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceProvider, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createResourceType(server *ucptesthost.TestHost) { + ctx := context.Background() + + resourceType := v20231001preview.ResourceTypeResource{ + Properties: &v20231001preview.ResourceTypeProperties{}, + } + + client := server.UCP().NewResourceTypesClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceTypeName, resourceType, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createAPIVersion(server *ucptesthost.TestHost) { + ctx := context.Background() + + apiVersionResource := v20231001preview.APIVersionResource{ + Properties: &v20231001preview.APIVersionProperties{}, + } + + client := server.UCP().NewAPIVersionsClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, resourceTypeName, apiVersion, apiVersionResource, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createLocation(server *ucptesthost.TestHost) { + ctx := context.Background() + + location := v20231001preview.LocationResource{ + Properties: &v20231001preview.LocationProperties{ + ResourceTypes: map[string]*v20231001preview.LocationResourceType{ + resourceTypeName: { + APIVersions: map[string]map[string]any{ + apiVersion: {}, + }, + }, + }, + }, + } + + client := server.UCP().NewLocationsClient() + poller, err := client.BeginCreateOrUpdate(ctx, radiusPlaneName, resourceProviderNamespace, locationName, location, nil) + require.NoError(server.T(), err) + + _, err = poller.PollUntilDone(ctx, nil) + require.NoError(server.T(), err) +} + +func createResourceGroup(server *ucptesthost.TestHost) { + ctx := context.Background() + + resourceGroup := v20231001preview.ResourceGroupResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &v20231001preview.ResourceGroupProperties{}, + } + + client := server.UCP().NewResourceGroupsClient() + _, err := client.CreateOrUpdate(ctx, radiusPlaneName, resourceGroupName, resourceGroup, nil) + require.NoError(server.T(), err) +} diff --git a/pkg/dynamicrp/options.go b/pkg/dynamicrp/options.go index 4ebaf80a1a..ac32ce8f62 100644 --- a/pkg/dynamicrp/options.go +++ b/pkg/dynamicrp/options.go @@ -21,7 +21,6 @@ import ( "fmt" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" - "github.com/radius-project/radius/pkg/armrpc/hostoptions" "github.com/radius-project/radius/pkg/components/database/databaseprovider" "github.com/radius-project/radius/pkg/components/queue/queueprovider" "github.com/radius-project/radius/pkg/components/secret/secretprovider" @@ -32,7 +31,10 @@ import ( kube_rest "k8s.io/client-go/rest" ) -// Options holds the configuration options and shared services for the server. +// Options holds the configuration options and shared services for the DyanmicRP server. +// +// For testability, all fields on this struct MUST be constructed from the NewOptions function without any +// additional initialization required. type Options struct { // Config is the configuration for the server. Config *Config @@ -80,14 +82,16 @@ func NewOptions(ctx context.Context, config *Config) (*Options, error) { options.StatusManager = statusmanager.New(databaseClient, queueClient, config.Environment.RoleLocation) var cfg *kube_rest.Config - cfg, err = kubeutil.NewClientConfig(&kubeutil.ConfigOptions{ - // TODO: Allow to use custom context via configuration. - https://github.com/radius-project/radius/issues/5433 - ContextName: "", - QPS: kubeutil.DefaultServerQPS, - Burst: kubeutil.DefaultServerBurst, - }) - if err != nil { - return nil, fmt.Errorf("failed to get kubernetes config: %w", err) + if config.UCP.Kind == ucpconfig.UCPConnectionKindKubernetes { + cfg, err = kubeutil.NewClientConfig(&kubeutil.ConfigOptions{ + // TODO: Allow to use custom context via configuration. - https://github.com/radius-project/radius/issues/5433 + ContextName: "", + QPS: kubeutil.DefaultServerQPS, + Burst: kubeutil.DefaultServerBurst, + }) + if err != nil { + return nil, fmt.Errorf("failed to get kubernetes config: %w", err) + } } options.UCP, err = ucpconfig.NewConnectionFromUCPConfig(&config.UCP, cfg) @@ -95,22 +99,28 @@ func NewOptions(ctx context.Context, config *Config) (*Options, error) { return nil, err } + // TODO: This is the right place to initialize the recipe infrastructure. Unfortunately this + // has a dependency on Kubernetes right now, which isn't available for integration tests. + // + // We have a future work item to untangle this dependency and then this code can be uncommented. + // For now this is a placeholder/reminder of the code we need, and where to put it. + // // The recipe infrastructure is tied to corerp's dependencies, so we need to create it here. - recipes, err := controllerconfig.New(hostoptions.HostOptions{ - Config: &hostoptions.ProviderConfig{ - Bicep: config.Bicep, - Env: config.Environment, - Terraform: config.Terraform, - UCP: config.UCP, - }, - K8sConfig: cfg, - UCPConnection: options.UCP, - }) - if err != nil { - return nil, err - } - - options.Recipes = recipes + // recipes, err := controllerconfig.New(hostoptions.HostOptions{ + // Config: &hostoptions.ProviderConfig{ + // Bicep: config.Bicep, + // Env: config.Environment, + // Terraform: config.Terraform, + // UCP: config.UCP, + // }, + // K8sConfig: cfg, + // UCPConnection: options.UCP, + // }) + // if err != nil { + // return nil, err + // } + // + // options.Recipes = recipes return &options, nil } diff --git a/pkg/dynamicrp/testhost/doc.go b/pkg/dynamicrp/testhost/doc.go new file mode 100644 index 0000000000..bdd65e049e --- /dev/null +++ b/pkg/dynamicrp/testhost/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2023 The Radius Authors. + +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. +*/ + +// testhost provides an implementation of a test server for the dynamic RP. +package testhost diff --git a/pkg/dynamicrp/testhost/host.go b/pkg/dynamicrp/testhost/host.go new file mode 100644 index 0000000000..d6b8045608 --- /dev/null +++ b/pkg/dynamicrp/testhost/host.go @@ -0,0 +1,138 @@ +/* +Copyright 2023 The Radius Authors. + +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 testhost + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/google/uuid" + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + "github.com/radius-project/radius/pkg/components/database/databaseprovider" + "github.com/radius-project/radius/pkg/components/queue/queueprovider" + "github.com/radius-project/radius/pkg/components/secret/secretprovider" + "github.com/radius-project/radius/pkg/components/testhost" + "github.com/radius-project/radius/pkg/dynamicrp" + "github.com/radius-project/radius/pkg/dynamicrp/server" + "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/ucp" + "github.com/radius-project/radius/pkg/ucp/config" + ucptesthost "github.com/radius-project/radius/pkg/ucp/testhost" + "github.com/stretchr/testify/require" +) + +// TestHostOptions supports configuring the dynamic-rp test host. +type TestHostOption interface { + // Apply applies the option to the dynamic-rp options. + Apply(options *dynamicrp.Options) +} + +// TestHostOptionFunc is a function that implements the TestHostOption interface. +type TestHostOptionFunc func(options *dynamicrp.Options) + +// Apply applies the function to the dynamic-rp options. +func (f TestHostOptionFunc) Apply(options *dynamicrp.Options) { + f(options) +} + +// TestHost provides a test host for the dynamic-rp server. +type TestHost struct { + *testhost.TestHost +} + +func Start(t *testing.T, opts ...TestHostOption) (*TestHost, *ucptesthost.TestHost) { + config := &dynamicrp.Config{ + Database: databaseprovider.Options{ + Provider: databaseprovider.TypeInMemory, + }, + Environment: hostoptions.EnvironmentOptions{ + Name: "test", + RoleLocation: v1.LocationGlobal, + }, + Queue: queueprovider.QueueProviderOptions{ + Provider: queueprovider.TypeInmemory, + Name: "dynamic-rp", + }, + Secrets: secretprovider.SecretProviderOptions{ + Provider: secretprovider.TypeInMemorySecret, + }, + Server: hostoptions.ServerOptions{ + // Initialized dynamically when the server is started. + }, + UCP: config.UCPOptions{ + Kind: config.UCPConnectionKindDirect, + Direct: &config.UCPDirectConnectionOptions{ + Endpoint: "http://localhost:65000", // Initialized dynamically when the server is started. + }, + }, + } + + options, err := dynamicrp.NewOptions(context.Background(), config) + require.NoError(t, err) + + for _, opt := range opts { + opt.Apply(options) + } + + return StartWithOptions(t, options) +} + +// StartWithOptions uses the provided options to start the dynamic-rp test host and an instance of UCP +// configured to route traffic to the dynamic-rp test host. +// +// Manually configuring the server information other than the port is not supported. +func StartWithOptions(t *testing.T, options *dynamicrp.Options) (*TestHost, *ucptesthost.TestHost) { + options.Config.Server.Host = "localhost" + options.Config.Server.PathBase = "/" + uuid.New().String() + if options.Config.Server.Port == 0 { + options.Config.Server.Port = testhost.AllocateFreePort(t) + } + + // Allocate a port for UCP. + ucpPort := testhost.AllocateFreePort(t) + options.Config.UCP.Kind = config.UCPConnectionKindDirect + options.Config.UCP.Direct = &config.UCPDirectConnectionOptions{Endpoint: fmt.Sprintf("http://localhost:%d", ucpPort)} + + var err error + options.UCP, err = sdk.NewDirectConnection(options.Config.UCP.Direct.Endpoint) + require.NoError(t, err) + + baseURL := fmt.Sprintf( + "http://%s%s", + options.Config.Server.Address(), + options.Config.Server.PathBase) + baseURL = strings.TrimSuffix(baseURL, "/") + + host, err := server.NewServer(options) + require.NoError(t, err, "failed to create server") + + th := testhost.StartHost(t, host, baseURL) + return &TestHost{th}, startUCP(t, baseURL, ucpPort) +} + +func startUCP(t *testing.T, dynamicRPURL string, ucpPort int) *ucptesthost.TestHost { + return ucptesthost.Start(t, ucptesthost.TestHostOptionFunc(func(options *ucp.Options) { + // Initialize UCP with its listening port + options.Config.Server.Port = ucpPort + + // Intitialize UCP with the dynamic-rp URL + options.Config.Routing.DefaultDownstreamEndpoint = dynamicRPURL + })) +} diff --git a/pkg/recipes/controllerconfig/config.go b/pkg/recipes/controllerconfig/config.go index e2f60fc312..40fbd98a2d 100644 --- a/pkg/recipes/controllerconfig/config.go +++ b/pkg/recipes/controllerconfig/config.go @@ -76,6 +76,14 @@ func New(options hostoptions.HostOptions) (*RecipeControllerConfig, error) { return nil, err } + if options.Config.Bicep.DeleteRetryCount == "" { + options.Config.Bicep.DeleteRetryCount = "3" + } + + if options.Config.Bicep.DeleteRetryDelaySeconds == "" { + options.Config.Bicep.DeleteRetryDelaySeconds = "10" + } + bicepDeleteRetryCount, err := strconv.Atoi(options.Config.Bicep.DeleteRetryCount) if err != nil { return nil, err diff --git a/pkg/server/asyncworker.go b/pkg/server/asyncworker.go index 2531c6b0a9..cc7ab8da1e 100644 --- a/pkg/server/asyncworker.go +++ b/pkg/server/asyncworker.go @@ -21,9 +21,12 @@ import ( "fmt" ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" + "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/worker" "github.com/radius-project/radius/pkg/armrpc/builder" "github.com/radius-project/radius/pkg/armrpc/hostoptions" + "github.com/radius-project/radius/pkg/components/database/databaseprovider" + "github.com/radius-project/radius/pkg/components/queue/queueprovider" "github.com/radius-project/radius/pkg/corerp/backend/deployment" "github.com/radius-project/radius/pkg/corerp/model" "github.com/radius-project/radius/pkg/kubeutil" @@ -33,17 +36,18 @@ import ( type AsyncWorker struct { worker.Service + options hostoptions.HostOptions handlerBuilder []builder.Builder } // NewAsyncWorker creates new service instance to run AsyncRequestProcessWorker. func NewAsyncWorker(options hostoptions.HostOptions, builder []builder.Builder) *AsyncWorker { return &AsyncWorker{ - Service: worker.Service{ - ProviderName: "radius", - Options: options, - }, + options: options, handlerBuilder: builder, + Service: worker.Service{ + // Will be initialized later + }, } } @@ -52,51 +56,73 @@ func (w *AsyncWorker) Name() string { return "radiusasyncworker" } -// Run starts the service and worker. -func (w *AsyncWorker) Run(ctx context.Context) error { - if err := w.Init(ctx); err != nil { +func (w *AsyncWorker) init(ctx context.Context) error { + workerOptions := worker.Options{} + if w.options.Config.WorkerServer != nil { + if w.options.Config.WorkerServer.MaxOperationConcurrency != nil { + workerOptions.MaxOperationConcurrency = *w.options.Config.WorkerServer.MaxOperationConcurrency + } + if w.options.Config.WorkerServer.MaxOperationRetryCount != nil { + workerOptions.MaxOperationRetryCount = *w.options.Config.WorkerServer.MaxOperationRetryCount + } + } + + queueProvider := queueprovider.New(w.options.Config.QueueProvider) + databaseProvider := databaseprovider.FromOptions(w.options.Config.DatabaseProvider) + + databaseClient, err := databaseProvider.GetClient(ctx) + if err != nil { + return err + } + + queueClient, err := queueProvider.GetClient(ctx) + if err != nil { return err } - k8s, err := kubeutil.NewClients(w.Options.K8sConfig) + statusManager := statusmanager.New(databaseClient, queueClient, w.options.Config.Env.RoleLocation) + + w.Service = worker.Service{ + DatabaseClient: databaseClient, + OperationStatusManager: statusManager, + Options: workerOptions, + QueueClient: queueClient, + } + + return nil +} + +// Run starts the service and worker. +func (w *AsyncWorker) Run(ctx context.Context) error { + k8s, err := kubeutil.NewClients(w.options.K8sConfig) if err != nil { return fmt.Errorf("failed to initialize kubernetes clients: %w", err) } - appModel, err := model.NewApplicationModel(w.Options.Arm, k8s.RuntimeClient, k8s.ClientSet, k8s.DiscoveryClient, k8s.DynamicClient) + appModel, err := model.NewApplicationModel(w.options.Arm, k8s.RuntimeClient, k8s.ClientSet, k8s.DiscoveryClient, k8s.DynamicClient) if err != nil { return fmt.Errorf("failed to initialize application model: %w", err) } - databaseClient, err := w.DatabaseProvider.GetClient(ctx) + err = w.init(ctx) if err != nil { - return err + return fmt.Errorf("failed to initialize async worker: %w", err) } for _, b := range w.handlerBuilder { opts := ctrl.Options{ - DatabaseClient: databaseClient, + DatabaseClient: w.DatabaseClient, KubeClient: k8s.RuntimeClient, GetDeploymentProcessor: func() deployment.DeploymentProcessor { - return deployment.NewDeploymentProcessor(appModel, databaseClient, k8s.RuntimeClient, k8s.ClientSet) + return deployment.NewDeploymentProcessor(appModel, w.DatabaseClient, k8s.RuntimeClient, k8s.ClientSet) }, } - err := b.ApplyAsyncHandler(ctx, w.Controllers, opts) + err := b.ApplyAsyncHandler(ctx, w.Controllers(), opts) if err != nil { panic(err) } } - workerOpts := worker.Options{} - if w.Options.Config.WorkerServer != nil { - if w.Options.Config.WorkerServer.MaxOperationConcurrency != nil { - workerOpts.MaxOperationConcurrency = *w.Options.Config.WorkerServer.MaxOperationConcurrency - } - if w.Options.Config.WorkerServer.MaxOperationRetryCount != nil { - workerOpts.MaxOperationRetryCount = *w.Options.Config.WorkerServer.MaxOperationRetryCount - } - } - - return w.Start(ctx, workerOpts) + return w.Start(ctx) } diff --git a/pkg/ucp/backend/service.go b/pkg/ucp/backend/service.go index c61ed873b6..acb284191f 100644 --- a/pkg/ucp/backend/service.go +++ b/pkg/ucp/backend/service.go @@ -19,88 +19,82 @@ package backend import ( "context" "errors" - "fmt" "net/http" "net/url" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/worker" - "github.com/radius-project/radius/pkg/armrpc/hostoptions" "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/backend/controller/resourcegroups" "github.com/radius-project/radius/pkg/ucp/backend/controller/resourceproviders" "github.com/radius-project/radius/pkg/ucp/datamodel" - ucpoptions "github.com/radius-project/radius/pkg/ucp/hostoptions" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) -const ( - UCPProviderName = "System.Resources" -) - // Service is a service to run AsyncReqeustProcessWorker. type Service struct { worker.Service - - config ucpoptions.UCPConfig + options *ucp.Options } -// NewService creates new service instance to run AsyncRequestProcessWorker. -func NewService(options hostoptions.HostOptions, config ucpoptions.UCPConfig) *Service { +// NewService creates new backend service instance to run the async worker. +func NewService(options *ucp.Options) *Service { return &Service{ + options: options, Service: worker.Service{ - ProviderName: UCPProviderName, - Options: options, + // Will be initialized later. + }, - config: config, } } -// Name returns a string containing the UCPProviderName and the text "async worker". +// Name returns the service name. func (w *Service) Name() string { - return fmt.Sprintf("%s async worker", UCPProviderName) + return "ucp async worker" } -// Run starts the service and worker. It initializes the service and sets the worker options based on the configuration, -// then starts the service with the given worker options. It returns an error if the initialization fails. +// Run starts the background worker. func (w *Service) Run(ctx context.Context) error { - if err := w.Init(ctx); err != nil { - return err + if w.options.Config.Worker.MaxOperationConcurrency != nil { + w.Service.Options.MaxOperationConcurrency = *w.options.Config.Worker.MaxOperationConcurrency + } + if w.options.Config.Worker.MaxOperationRetryCount != nil { + w.Service.Options.MaxOperationRetryCount = *w.options.Config.Worker.MaxOperationRetryCount } - workerOpts := worker.Options{} - if w.Options.Config.WorkerServer != nil { - if w.Options.Config.WorkerServer.MaxOperationConcurrency != nil { - workerOpts.MaxOperationConcurrency = *w.Options.Config.WorkerServer.MaxOperationConcurrency - } - if w.Options.Config.WorkerServer.MaxOperationRetryCount != nil { - workerOpts.MaxOperationRetryCount = *w.Options.Config.WorkerServer.MaxOperationRetryCount - } + databaseClient, err := w.options.DatabaseProvider.GetClient(ctx) + if err != nil { + return err } - databaseClient, err := w.DatabaseProvider.GetClient(ctx) + queueClient, err := w.options.QueueProvider.GetClient(ctx) if err != nil { return err } + w.Service.DatabaseClient = databaseClient + w.Service.QueueClient = queueClient + w.Service.OperationStatusManager = w.options.StatusManager + opts := ctrl.Options{ DatabaseClient: databaseClient, } - defaultDownstream, err := url.Parse(w.config.Routing.DefaultDownstreamEndpoint) + defaultDownstream, err := url.Parse(w.options.Config.Routing.DefaultDownstreamEndpoint) if err != nil { return err } transport := otelhttp.NewTransport(http.DefaultTransport) - err = RegisterControllers(w.Controllers, w.Options.UCPConnection, transport, opts, defaultDownstream) + err = RegisterControllers(w.Controllers(), w.options.UCP, transport, opts, defaultDownstream) if err != nil { return err } - return w.Start(ctx, workerOpts) + return w.Start(ctx) } // RegisterControllers registers the controllers for the UCP backend. diff --git a/pkg/ucp/config.go b/pkg/ucp/config.go new file mode 100644 index 0000000000..7ef4a8ff56 --- /dev/null +++ b/pkg/ucp/config.go @@ -0,0 +1,153 @@ +/* +Copyright 2023 The Radius Authors. + +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 ucp + +import ( + "bytes" + + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + "github.com/radius-project/radius/pkg/components/database/databaseprovider" + "github.com/radius-project/radius/pkg/components/queue/queueprovider" + "github.com/radius-project/radius/pkg/components/secret/secretprovider" + metricsprovider "github.com/radius-project/radius/pkg/metrics/provider" + profilerprovider "github.com/radius-project/radius/pkg/profiler/provider" + "github.com/radius-project/radius/pkg/trace" + ucpconfig "github.com/radius-project/radius/pkg/ucp/config" + "github.com/radius-project/radius/pkg/ucp/ucplog" + "gopkg.in/yaml.v3" +) + +// Config defines the configuration for the UCP server. +// +// For testability, all fields on this struct MUST be parsable from YAML without any further initialization required. +type Config struct { + // Database is the configuration for the database used for resource data. + Database databaseprovider.Options `yaml:"databaseProvider"` + + // Environment is the configuration for the hosting environment. + Environment hostoptions.EnvironmentOptions `yaml:"environment"` + + // Identity is the configuration for authenticating with external systems like Azure and AWS. + Identity IdentityConfig `yaml:"identity"` + + // Initialization is the configuration for initializing the UCP server. + Initialization InitializationConfig `yaml:"initialization"` + + // Logging is the configuration for the logging system. + Logging ucplog.LoggingOptions `yaml:"logging"` + + // Metrics is the configuration for the metrics endpoint. + Metrics metricsprovider.MetricsProviderOptions `yaml:"metricsProvider"` + + // Profiler is the configuration for the profiler endpoint. + Profiler profilerprovider.ProfilerProviderOptions `yaml:"profilerProvider"` + + // Routing is the configuration for UCP routing. + Routing RoutingConfig `yaml:"routing"` + + // Queue is the configuration for the message queue. + Queue queueprovider.QueueProviderOptions `yaml:"queueProvider"` + + // Secrets is the configuration for the secret storage system. + Secrets secretprovider.SecretProviderOptions `yaml:"secretProvider"` + + // Server is the configuration for the HTTP server. + Server hostoptions.ServerOptions `yaml:"server"` + + // Tracing is the configuration for the tracing system. + Tracing trace.Options `yaml:"tracerProvider"` + + // UCPConfig is the configuration for the connection to UCP. + UCP ucpconfig.UCPOptions `yaml:"ucp"` + + // Worker is the configuration for the backend worker server. + Worker hostoptions.WorkerServerOptions `yaml:"workerServer"` +} + +const ( + // AuthUCPCredential is the authentication method via UCP Credential API. + AuthUCPCredential = "UCPCredential" + + // AuthDefault is the default authentication method, such as environment variables. + AuthDefault = "default" +) + +// Identity represents configuration options for authenticating with external systems like Azure and AWS. +type IdentityConfig struct { + // AuthMethod represents the method of authentication for authenticating with external systems like Azure and AWS. + AuthMethod string `yaml:"authMethod"` +} + +// RoutingConfig provides configuration for UCP routing. +type RoutingConfig struct { + // DefaultDownstreamEndpoint is the default destination when a resource provider does not provide a downstream endpoint. + // In practice, this points to the URL of dynamic-rp. + DefaultDownstreamEndpoint string `yaml:"defaultDownstreamEndpoint"` +} + +// InitializeConfig defines the configuration for initializing the UCP server. +// +// This includes resources that are added to UCP's data on startup. +// +// TODO: this will be generalized as part of the UDT work. Right now it only +// handles planes, and we need to support other kinds of resources. +type InitializationConfig struct { + // Planes is a list of planes to create at startup. + Planes []Plane `yaml:"planes,omitempty"` +} + +// Plane is a configuration entry for a plane resource. This is used to create a plane resource at startup. +type Plane struct { + // ID is the resource ID of the plane. + ID string `json:"id" yaml:"id"` + + // Type is the resource type of the plane. + Type string `json:"type" yaml:"type"` + + // Name is the resource name of the plane. + Name string `json:"name" yaml:"name"` + + // Properties is the properties of the plane. + Properties PlaneProperties `json:"properties" yaml:"properties"` +} + +type PlaneProperties struct { + // ResourceProviders is a map of resource provider namespaces to their respective addresses. + // + // This is part of legacy (non-UDT) support for planes and will be removed. + ResourceProviders map[string]string `json:"resourceProviders" yaml:"resourceProviders"` + + // Kind is the legacy UCP plane type. + Kind string `json:"kind" yaml:"kind"` + + // URL is the downsteam URL for the plane. + URL string `json:"url" yaml:"url"` +} + +// LoadConfig loads a Config from bytes. +func LoadConfig(bs []byte) (*Config, error) { + decoder := yaml.NewDecoder(bytes.NewBuffer(bs)) + decoder.KnownFields(true) + + config := Config{} + err := decoder.Decode(&config) + if err != nil { + return nil, err + } + + return &config, nil +} diff --git a/pkg/ucp/config/ucpoptions.go b/pkg/ucp/config/ucpoptions.go index 57583033ae..785a2efcc7 100644 --- a/pkg/ucp/config/ucpoptions.go +++ b/pkg/ucp/config/ucpoptions.go @@ -59,6 +59,9 @@ func NewConnectionFromUCPConfig(option *UCPOptions, k8sConfig *rest.Config) (sdk return nil, errors.New("the property .ucp.direct.endpoint is required when using a direct connection") } return sdk.NewDirectConnection(option.Direct.Endpoint) + } else if option.Kind == UCPConnectionKindKubernetes { + return sdk.NewKubernetesConnectionFromConfig(k8sConfig) } - return sdk.NewKubernetesConnectionFromConfig(k8sConfig) + + return nil, errors.New("invalid connection kind: " + option.Kind) } diff --git a/pkg/ucp/datamodel/resourcegroup.go b/pkg/ucp/datamodel/resourcegroup.go index 0272b954a6..353e2b4a3e 100644 --- a/pkg/ucp/datamodel/resourcegroup.go +++ b/pkg/ucp/datamodel/resourcegroup.go @@ -18,6 +18,11 @@ package datamodel import v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" +const ( + // ResourceGroupResourceType is the type of a resource group. + ResourceGroupResourceType = "System.Resources/resourceGroups" +) + // ResourceGroup represents UCP ResourceGroup. type ResourceGroup struct { v1.BaseResource @@ -25,5 +30,5 @@ type ResourceGroup struct { // ResourceTypeName returns a string representing the resource type name of the ResourceGroup object. func (p ResourceGroup) ResourceTypeName() string { - return "System.Resources/resourceGroups" + return ResourceGroupResourceType } diff --git a/pkg/ucp/doc.go b/pkg/ucp/doc.go new file mode 100644 index 0000000000..44a63b58c4 --- /dev/null +++ b/pkg/ucp/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2023 The Radius Authors. + +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. +*/ + +// ucp holds the configuration and options types for UCP. See the packages nested inside this +// one for the UCP implementation. +package ucp diff --git a/pkg/ucp/frontend/api/routes.go b/pkg/ucp/frontend/api/routes.go index b8e732cb79..a1c050f44d 100644 --- a/pkg/ucp/frontend/api/routes.go +++ b/pkg/ucp/frontend/api/routes.go @@ -26,6 +26,7 @@ import ( v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/armrpc/frontend/controller" "github.com/radius-project/radius/pkg/armrpc/frontend/server" + "github.com/radius-project/radius/pkg/ucp" kubernetes_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/kubernetes" planes_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/planes" "github.com/radius-project/radius/pkg/ucp/frontend/modules" @@ -74,9 +75,9 @@ func initModules(ctx context.Context, mods []modules.Initializer) (map[string]ht } // Register registers the routes for UCP including modules. -func Register(ctx context.Context, router chi.Router, planeModules []modules.Initializer, options modules.Options) error { +func Register(ctx context.Context, router chi.Router, planeModules []modules.Initializer, options *ucp.Options) error { logger := ucplog.FromContextOrDiscard(ctx) - logger.Info(fmt.Sprintf("Registering routes with path base: %s", options.PathBase)) + logger.Info(fmt.Sprintf("Registering routes with path base: %s", options.Config.Server.PathBase)) router.NotFound(validator.APINotFoundHandler()) router.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) @@ -89,7 +90,7 @@ func Register(ctx context.Context, router chi.Router, planeModules []modules.Ini handlerOptions := []server.HandlerOptions{} // If we're in Kubernetes we have some required routes to implement. - if options.PathBase != "" { + if options.Config.Server.PathBase != "" { // NOTE: the Kubernetes API Server does not include the gvr (base path) in // the URL for swagger routes. handlerOptions = append(handlerOptions, []server.HandlerOptions{ @@ -111,7 +112,7 @@ func Register(ctx context.Context, router chi.Router, planeModules []modules.Ini }, { ParentRouter: router, - Path: options.PathBase, + Path: options.Config.Server.PathBase, OperationType: &v1.OperationType{Type: OperationTypeKubernetesDiscoveryDoc, Method: v1.OperationGet}, ResourceType: OperationTypeKubernetesDiscoveryDoc, Method: v1.OperationGet, @@ -127,7 +128,7 @@ func Register(ctx context.Context, router chi.Router, planeModules []modules.Ini }) // Configures planes collection and resource routes. - planeCollectionRouter := server.NewSubrouter(router, options.PathBase+planeCollectionPath, apiValidator) + planeCollectionRouter := server.NewSubrouter(router, options.Config.Server.PathBase+planeCollectionPath, apiValidator) // The "list all planes by type" handler is registered here. handlerOptions = append(handlerOptions, []server.HandlerOptions{ @@ -148,10 +149,13 @@ func Register(ctx context.Context, router chi.Router, planeModules []modules.Ini } ctrlOptions := controller.Options{ - Address: options.Address, - PathBase: options.PathBase, + Address: options.Config.Server.Address(), DatabaseClient: databaseClient, + PathBase: options.Config.Server.PathBase, StatusManager: options.StatusManager, + + KubeClient: nil, // Unused by UCP + ResourceType: "", // Set dynamically } for _, h := range handlerOptions { @@ -161,7 +165,7 @@ func Register(ctx context.Context, router chi.Router, planeModules []modules.Ini } // Register a catch-all route to handle requests that get dispatched to a specific plane. - unknownPlaneRouter := server.NewSubrouter(router, options.PathBase+planeTypeCollectionPath) + unknownPlaneRouter := server.NewSubrouter(router, options.Config.Server.PathBase+planeTypeCollectionPath) unknownPlaneRouter.HandleFunc(server.CatchAllPath, func(w http.ResponseWriter, r *http.Request) { planeType := chi.URLParam(r, "planeType") handler, ok := moduleHandlers[planeType] diff --git a/pkg/ucp/frontend/api/routes_test.go b/pkg/ucp/frontend/api/routes_test.go index 46ee16b30c..fe91a73422 100644 --- a/pkg/ucp/frontend/api/routes_test.go +++ b/pkg/ucp/frontend/api/routes_test.go @@ -24,8 +24,11 @@ import ( "github.com/go-chi/chi/v5" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" + "github.com/radius-project/radius/pkg/armrpc/hostoptions" "github.com/radius-project/radius/pkg/armrpc/rpctest" "github.com/radius-project/radius/pkg/components/database/databaseprovider" + "github.com/radius-project/radius/pkg/components/secret/secretprovider" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/frontend/modules" "github.com/radius-project/radius/test/testcontext" "github.com/stretchr/testify/require" @@ -79,10 +82,16 @@ func Test_Routes(t *testing.T) { }, } - options := modules.Options{ - Address: "localhost", - PathBase: pathBase, + options := &ucp.Options{ + Config: &ucp.Config{ + Server: hostoptions.ServerOptions{ + Host: "localhost", + Port: 8080, + PathBase: pathBase, + }, + }, DatabaseProvider: databaseprovider.FromMemory(), + SecretProvider: secretprovider.NewSecretProvider(secretprovider.SecretProviderOptions{Provider: secretprovider.TypeInMemorySecret}), StatusManager: statusmanager.NewMockStatusManager(gomock.NewController(t)), } @@ -95,10 +104,16 @@ func Test_Routes(t *testing.T) { func Test_Route_ToModule(t *testing.T) { pathBase := "/some-path-base" - options := modules.Options{ - Address: "localhost", - PathBase: pathBase, + options := &ucp.Options{ + Config: &ucp.Config{ + Server: hostoptions.ServerOptions{ + Host: "localhost", + Port: 8080, + PathBase: pathBase, + }, + }, DatabaseProvider: databaseprovider.FromMemory(), + SecretProvider: secretprovider.NewSecretProvider(secretprovider.SecretProviderOptions{Provider: secretprovider.TypeInMemorySecret}), StatusManager: statusmanager.NewMockStatusManager(gomock.NewController(t)), } diff --git a/pkg/ucp/frontend/api/server.go b/pkg/ucp/frontend/api/server.go index bd851d967a..3dc47ad9b3 100644 --- a/pkg/ucp/frontend/api/server.go +++ b/pkg/ucp/frontend/api/server.go @@ -26,15 +26,11 @@ import ( "strings" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" - "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" armrpc_controller "github.com/radius-project/radius/pkg/armrpc/frontend/controller" "github.com/radius-project/radius/pkg/armrpc/frontend/defaultoperation" "github.com/radius-project/radius/pkg/armrpc/servicecontext" - "github.com/radius-project/radius/pkg/components/database/databaseprovider" - "github.com/radius-project/radius/pkg/components/queue/queueprovider" - "github.com/radius-project/radius/pkg/components/secret/secretprovider" "github.com/radius-project/radius/pkg/middleware" - "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/datamodel" "github.com/radius-project/radius/pkg/ucp/datamodel/converter" aws_frontend "github.com/radius-project/radius/pkg/ucp/frontend/aws" @@ -43,54 +39,21 @@ import ( radius_frontend "github.com/radius-project/radius/pkg/ucp/frontend/radius" "github.com/radius-project/radius/pkg/ucp/frontend/versions" "github.com/radius-project/radius/pkg/ucp/hosting" - "github.com/radius-project/radius/pkg/ucp/hostoptions" "github.com/radius-project/radius/pkg/ucp/resources" - "github.com/radius-project/radius/pkg/ucp/rest" "github.com/radius-project/radius/pkg/ucp/ucplog" - "github.com/radius-project/radius/pkg/validator" - "github.com/radius-project/radius/swagger" "github.com/go-chi/chi/v5" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" ) -const ( - DefaultPlanesConfig = "DEFAULT_PLANES_CONFIG" -) - -type ServiceOptions struct { - // Config is the bootstrap configuration loaded from config file. - Config *hostoptions.UCPConfig - - ProviderName string - Address string - PathBase string - Configure func(chi.Router) - TLSCertDir string - DefaultPlanesConfigFile string - DatabaseProviderOptions databaseprovider.Options - SecretProviderOptions secretprovider.SecretProviderOptions - QueueProviderOptions queueprovider.QueueProviderOptions - InitialPlanes []rest.Plane - Identity hostoptions.Identity - UCPConnection sdk.Connection - Location string - - // Modules is a list of modules that will be registered with the router. - Modules []modules.Initializer -} - // Service implements the hosting.Service interface for the UCP frontend API. type Service struct { - options ServiceOptions - databaseProvider *databaseprovider.DatabaseProvider - queueProvider *queueprovider.QueueProvider - secretProvider *secretprovider.SecretProvider + options *ucp.Options } // DefaultModules returns a list of default modules that will be registered with the router. -func DefaultModules(options modules.Options) []modules.Initializer { +func DefaultModules(options *ucp.Options) []modules.Initializer { return []modules.Initializer{ aws_frontend.NewModule(options), azure_frontend.NewModule(options), @@ -101,7 +64,7 @@ func DefaultModules(options modules.Options) []modules.Initializer { var _ hosting.Service = (*Service)(nil) // NewService creates a server to serve UCP API requests. -func NewService(options ServiceOptions) *Service { +func NewService(options *ucp.Options) *Service { return &Service{ options: options, } @@ -118,57 +81,25 @@ func (s *Service) Name() string { func (s *Service) Initialize(ctx context.Context) (*http.Server, error) { r := chi.NewRouter() - s.databaseProvider = databaseprovider.FromOptions(s.options.DatabaseProviderOptions) - s.queueProvider = queueprovider.New(s.options.QueueProviderOptions) - s.secretProvider = secretprovider.NewSecretProvider(s.options.SecretProviderOptions) - - specLoader, err := validator.LoadSpec(ctx, "ucp", swagger.SpecFilesUCP, []string{s.options.PathBase}, "") - if err != nil { - return nil, err + // Allow tests to override the default modules. + modules := s.options.Modules + if modules == nil { + // If unset, use the default modules. + modules = DefaultModules(s.options) } - databaseClient, err := s.databaseProvider.GetClient(ctx) + err := Register(ctx, r, modules, s.options) if err != nil { return nil, err } - queueClient, err := s.queueProvider.GetClient(ctx) - if err != nil { - return nil, err - } - - statusManager := statusmanager.New(databaseClient, queueClient, s.options.Location) - - moduleOptions := modules.Options{ - Address: s.options.Address, - PathBase: s.options.PathBase, - Config: s.options.Config, - Location: s.options.Location, - DatabaseProvider: s.databaseProvider, - QueueProvider: s.queueProvider, - SecretProvider: s.secretProvider, - SpecLoader: specLoader, - StatusManager: statusManager, - UCPConnection: s.options.UCPConnection, - } - - modules := DefaultModules(moduleOptions) - err = Register(ctx, r, modules, moduleOptions) - if err != nil { - return nil, err - } - - if s.options.Configure != nil { - s.options.Configure(r) - } - err = s.configureDefaultPlanes(ctx) if err != nil { return nil, err } app := http.Handler(r) - app = servicecontext.ARMRequestCtx(s.options.PathBase, "global")(app) + app = servicecontext.ARMRequestCtx(s.options.Config.Server.PathBase, s.options.Config.Environment.RoleLocation)(app) app = middleware.WithLogger(app) app = otelhttp.NewHandler( @@ -182,7 +113,7 @@ func (s *Service) Initialize(ctx context.Context) (*http.Server, error) { app = middleware.RemoveRemoteAddr(app) server := &http.Server{ - Addr: s.options.Address, + Addr: s.options.Config.Server.Address(), // Need to be able to respond to requests with planes and resourcegroups segments with any casing e.g.: /Planes, /resourceGroups // AWS SDK is case sensitive. Therefore, cannot use lowercase middleware. Therefore, introducing a new middleware that translates // the path for only these segments and preserves the case for the other parts of the path. @@ -197,7 +128,7 @@ func (s *Service) Initialize(ctx context.Context) (*http.Server, error) { // configureDefaultPlanes reads the configuration file specified by the env var to configure default planes into UCP func (s *Service) configureDefaultPlanes(ctx context.Context) error { - for _, plane := range s.options.InitialPlanes { + for _, plane := range s.options.Config.Initialization.Planes { err := s.createPlane(ctx, plane) if err != nil { return err @@ -207,7 +138,7 @@ func (s *Service) configureDefaultPlanes(ctx context.Context) error { return nil } -func (s *Service) createPlane(ctx context.Context, plane rest.Plane) error { +func (s *Service) createPlane(ctx context.Context, plane ucp.Plane) error { body, err := json.Marshal(plane) if err != nil { return err @@ -222,7 +153,7 @@ func (s *Service) createPlane(ctx context.Context, plane rest.Plane) error { return fmt.Errorf("invalid plane ID: %s", plane.ID) } - db, err := s.databaseProvider.GetClient(ctx) + db, err := s.options.DatabaseProvider.GetClient(ctx) if err != nil { return err } @@ -271,7 +202,7 @@ func (s *Service) createPlane(ctx context.Context, plane rest.Plane) error { // Wrap the request in an ARM RPC context because this call will bypass the middleware // that normally does this for us. - rpcContext, err := v1.FromARMRequest(request, s.options.PathBase, s.options.Location) + rpcContext, err := v1.FromARMRequest(request, s.options.Config.Server.PathBase, s.options.Config.Environment.RoleLocation) if err != nil { return err } @@ -301,11 +232,11 @@ func (s *Service) Run(ctx context.Context) error { _ = service.Shutdown(ctx) }() - logger.Info(fmt.Sprintf("listening on: '%s'...", s.options.Address)) - if s.options.TLSCertDir == "" { + logger.Info(fmt.Sprintf("listening on: '%s'...", s.options.Config.Server.Address())) + if s.options.Config.Server.TLSCertificateDirectory == "" { err = service.ListenAndServe() } else { - err = service.ListenAndServeTLS(s.options.TLSCertDir+"/tls.crt", s.options.TLSCertDir+"/tls.key") + err = service.ListenAndServeTLS(s.options.Config.Server.TLSCertificateDirectory+"/tls.crt", s.options.Config.Server.TLSCertificateDirectory+"/tls.key") } if err == http.ErrServerClosed { diff --git a/pkg/ucp/frontend/aws/module.go b/pkg/ucp/frontend/aws/module.go index 676b4d0966..776c1df0cd 100644 --- a/pkg/ucp/frontend/aws/module.go +++ b/pkg/ucp/frontend/aws/module.go @@ -18,6 +18,7 @@ package aws import ( "github.com/go-chi/chi/v5" + "github.com/radius-project/radius/pkg/ucp" ucp_aws "github.com/radius-project/radius/pkg/ucp/aws" "github.com/radius-project/radius/pkg/ucp/frontend/modules" "github.com/radius-project/radius/pkg/validator" @@ -32,7 +33,7 @@ const ( ) // NewModule creates a new AWS module. -func NewModule(options modules.Options) *Module { +func NewModule(options *ucp.Options) *Module { m := Module{options: options} m.router = chi.NewRouter() m.router.NotFound(validator.APINotFoundHandler()) @@ -45,7 +46,7 @@ var _ modules.Initializer = &Module{} // Module defines the module for AWS functionality. type Module struct { - options modules.Options + options *ucp.Options router chi.Router // AWSClients provides access to AWS services. This field can be overridden by tests. diff --git a/pkg/ucp/frontend/aws/routes.go b/pkg/ucp/frontend/aws/routes.go index 7923343d9d..8bf2ba9327 100644 --- a/pkg/ucp/frontend/aws/routes.go +++ b/pkg/ucp/frontend/aws/routes.go @@ -29,6 +29,7 @@ import ( "github.com/radius-project/radius/pkg/armrpc/frontend/defaultoperation" "github.com/radius-project/radius/pkg/armrpc/frontend/server" aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" ucp_aws "github.com/radius-project/radius/pkg/ucp/aws" sdk_cred "github.com/radius-project/radius/pkg/ucp/credentials" @@ -37,7 +38,6 @@ import ( awsproxy_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/awsproxy" aws_credential_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/credentials/aws" planes_ctrl "github.com/radius-project/radius/pkg/ucp/frontend/controller/planes" - "github.com/radius-project/radius/pkg/ucp/hostoptions" "github.com/radius-project/radius/pkg/ucp/ucplog" "github.com/radius-project/radius/pkg/validator" ) @@ -80,7 +80,7 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { } } - baseRouter := server.NewSubrouter(m.router, m.options.PathBase) + baseRouter := server.NewSubrouter(m.router, m.options.Config.Server.PathBase+"/") apiValidator := validator.APIValidator(validator.Options{ SpecLoader: m.options.SpecLoader, @@ -101,8 +101,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { // This is a scope query so we can't use the default operation. ParentRouter: planeCollectionRouter, Method: v1.OperationList, - OperationType: &v1.OperationType{Type: datamodel.AWSPlaneResourceType, Method: v1.OperationList}, ResourceType: datamodel.AWSPlaneResourceType, + OperationType: &v1.OperationType{Type: datamodel.AWSPlaneResourceType, Method: v1.OperationList}, ControllerFactory: func(opts controller.Options) (controller.Controller, error) { return &planes_ctrl.ListPlanesByType[*datamodel.AWSPlane, datamodel.AWSPlane]{ Operation: controller.NewOperation(opts, planeResourceOptions), @@ -112,8 +112,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { { ParentRouter: planeResourceRouter, Method: v1.OperationGet, - OperationType: &v1.OperationType{Type: datamodel.AWSPlaneResourceType, Method: v1.OperationGet}, ResourceType: datamodel.AWSPlaneResourceType, + OperationType: &v1.OperationType{Type: datamodel.AWSPlaneResourceType, Method: v1.OperationGet}, ControllerFactory: func(opts controller.Options) (controller.Controller, error) { return defaultoperation.NewGetResource(opts, planeResourceOptions) }, @@ -121,8 +121,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { { ParentRouter: planeResourceRouter, Method: v1.OperationPut, - OperationType: &v1.OperationType{Type: datamodel.AWSPlaneResourceType, Method: v1.OperationPut}, ResourceType: datamodel.AWSPlaneResourceType, + OperationType: &v1.OperationType{Type: datamodel.AWSPlaneResourceType, Method: v1.OperationPut}, ControllerFactory: func(opts controller.Options) (controller.Controller, error) { return defaultoperation.NewDefaultSyncPut(opts, planeResourceOptions) }, @@ -130,8 +130,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { { ParentRouter: planeResourceRouter, Method: v1.OperationDelete, - OperationType: &v1.OperationType{Type: datamodel.AWSPlaneResourceType, Method: v1.OperationDelete}, ResourceType: datamodel.AWSPlaneResourceType, + OperationType: &v1.OperationType{Type: datamodel.AWSPlaneResourceType, Method: v1.OperationDelete}, ControllerFactory: func(opts controller.Options) (controller.Controller, error) { return defaultoperation.NewDefaultSyncDelete(opts, planeResourceOptions) }, @@ -298,10 +298,13 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { } ctrlOpts := controller.Options{ - Address: m.options.Address, - PathBase: m.options.PathBase, + Address: m.options.Config.Server.Address(), DatabaseClient: databaseClient, + PathBase: m.options.Config.Server.PathBase, StatusManager: m.options.StatusManager, + + KubeClient: nil, // Unused by AWS module + ResourceType: "", // Set dynamically } for _, h := range handlerOptions { @@ -318,8 +321,8 @@ func (m *Module) newAWSConfig(ctx context.Context) (aws.Config, error) { credProviders := []func(*config.LoadOptions) error{} switch m.options.Config.Identity.AuthMethod { - case hostoptions.AuthUCPCredential: - provider, err := sdk_cred.NewAWSCredentialProvider(m.options.SecretProvider, m.options.UCPConnection, &aztoken.AnonymousCredential{}) + case ucp.AuthUCPCredential: + provider, err := sdk_cred.NewAWSCredentialProvider(m.options.SecretProvider, m.options.UCP, &aztoken.AnonymousCredential{}) if err != nil { return aws.Config{}, err } diff --git a/pkg/ucp/frontend/aws/routes_test.go b/pkg/ucp/frontend/aws/routes_test.go index 8d65dfa39a..321e7e049d 100644 --- a/pkg/ucp/frontend/aws/routes_test.go +++ b/pkg/ucp/frontend/aws/routes_test.go @@ -26,14 +26,14 @@ import ( v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" + "github.com/radius-project/radius/pkg/armrpc/hostoptions" "github.com/radius-project/radius/pkg/armrpc/rpctest" "github.com/radius-project/radius/pkg/components/database/databaseprovider" "github.com/radius-project/radius/pkg/components/secret" "github.com/radius-project/radius/pkg/components/secret/secretprovider" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/datamodel" - "github.com/radius-project/radius/pkg/ucp/frontend/modules" - "github.com/radius-project/radius/pkg/ucp/hostoptions" ) const pathBase = "/some-path-base" @@ -117,10 +117,14 @@ func Test_Routes(t *testing.T) { secretProvider := secretprovider.NewSecretProvider(secretprovider.SecretProviderOptions{}) secretProvider.SetClient(secretClient) - options := modules.Options{ - Address: "localhost", - PathBase: pathBase, - Config: &hostoptions.UCPConfig{}, + options := &ucp.Options{ + Config: &ucp.Config{ + Server: hostoptions.ServerOptions{ + Host: "localhost", + Port: 8080, + PathBase: pathBase, + }, + }, DatabaseProvider: databaseprovider.FromMemory(), SecretProvider: secretProvider, StatusManager: statusmanager.NewMockStatusManager(gomock.NewController(t)), diff --git a/pkg/ucp/frontend/azure/module.go b/pkg/ucp/frontend/azure/module.go index d956d6ce30..24404996f0 100644 --- a/pkg/ucp/frontend/azure/module.go +++ b/pkg/ucp/frontend/azure/module.go @@ -18,25 +18,25 @@ package azure import ( "github.com/go-chi/chi/v5" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/frontend/modules" "github.com/radius-project/radius/pkg/validator" ) // NewModule creates a new Azure module. -func NewModule(options modules.Options) *Module { - m := Module{options: options} - m.router = chi.NewRouter() - m.router.NotFound(validator.APINotFoundHandler()) - m.router.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) +func NewModule(options *ucp.Options) *Module { + router := chi.NewRouter() + router.NotFound(validator.APINotFoundHandler()) + router.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) - return &Module{options: options, router: m.router} + return &Module{options: options, router: router} } var _ modules.Initializer = &Module{} // Module defines the module for Azure functionality. type Module struct { - options modules.Options + options *ucp.Options router chi.Router } diff --git a/pkg/ucp/frontend/azure/routes.go b/pkg/ucp/frontend/azure/routes.go index b77d70afe8..285d5a101b 100644 --- a/pkg/ucp/frontend/azure/routes.go +++ b/pkg/ucp/frontend/azure/routes.go @@ -49,7 +49,7 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { return nil, err } - baseRouter := server.NewSubrouter(m.router, m.options.PathBase) + baseRouter := server.NewSubrouter(m.router, m.options.Config.Server.PathBase+"/") apiValidator := validator.APIValidator(validator.Options{ SpecLoader: m.options.SpecLoader, @@ -73,8 +73,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { // This is a scope query so we can't use the default operation. ParentRouter: planeCollectionRouter, Method: v1.OperationList, - OperationType: &v1.OperationType{Type: datamodel.AzurePlaneResourceType, Method: v1.OperationList}, ResourceType: datamodel.AzurePlaneResourceType, + OperationType: &v1.OperationType{Type: datamodel.AzurePlaneResourceType, Method: v1.OperationList}, ControllerFactory: func(opts controller.Options) (controller.Controller, error) { return &planes_ctrl.ListPlanesByType[*datamodel.AzurePlane, datamodel.AzurePlane]{ Operation: controller.NewOperation(opts, planeResourceOptions), @@ -84,8 +84,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { { ParentRouter: planeResourceRouter, Method: v1.OperationGet, - OperationType: &v1.OperationType{Type: datamodel.AzurePlaneResourceType, Method: v1.OperationGet}, ResourceType: datamodel.AzurePlaneResourceType, + OperationType: &v1.OperationType{Type: datamodel.AzurePlaneResourceType, Method: v1.OperationGet}, ControllerFactory: func(opts controller.Options) (controller.Controller, error) { return defaultoperation.NewGetResource(opts, planeResourceOptions) }, @@ -93,8 +93,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { { ParentRouter: planeResourceRouter, Method: v1.OperationPut, - OperationType: &v1.OperationType{Type: datamodel.AzurePlaneResourceType, Method: v1.OperationPut}, ResourceType: datamodel.AzurePlaneResourceType, + OperationType: &v1.OperationType{Type: datamodel.AzurePlaneResourceType, Method: v1.OperationPut}, ControllerFactory: func(opts controller.Options) (controller.Controller, error) { return defaultoperation.NewDefaultSyncPut(opts, planeResourceOptions) }, @@ -102,8 +102,8 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { { ParentRouter: planeResourceRouter, Method: v1.OperationDelete, - OperationType: &v1.OperationType{Type: datamodel.AzurePlaneResourceType, Method: v1.OperationDelete}, ResourceType: datamodel.AzurePlaneResourceType, + OperationType: &v1.OperationType{Type: datamodel.AzurePlaneResourceType, Method: v1.OperationDelete}, ControllerFactory: func(opts controller.Options) (controller.Controller, error) { return defaultoperation.NewDefaultSyncDelete(opts, planeResourceOptions) }, @@ -171,10 +171,13 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { } ctrlOpts := controller.Options{ - Address: m.options.Address, - PathBase: m.options.PathBase, + Address: m.options.Config.Server.Address(), DatabaseClient: databaseClient, + PathBase: m.options.Config.Server.PathBase, StatusManager: m.options.StatusManager, + + KubeClient: nil, // Unused by Azure module + ResourceType: "", // Set dynamically } for _, h := range handlerOptions { diff --git a/pkg/ucp/frontend/azure/routes_test.go b/pkg/ucp/frontend/azure/routes_test.go index 818031e22c..50ecb61f1f 100644 --- a/pkg/ucp/frontend/azure/routes_test.go +++ b/pkg/ucp/frontend/azure/routes_test.go @@ -26,14 +26,14 @@ import ( v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" + "github.com/radius-project/radius/pkg/armrpc/hostoptions" "github.com/radius-project/radius/pkg/armrpc/rpctest" "github.com/radius-project/radius/pkg/components/database/databaseprovider" "github.com/radius-project/radius/pkg/components/secret" "github.com/radius-project/radius/pkg/components/secret/secretprovider" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/datamodel" - "github.com/radius-project/radius/pkg/ucp/frontend/modules" - "github.com/radius-project/radius/pkg/ucp/hostoptions" ) const pathBase = "/some-path-base" @@ -91,10 +91,14 @@ func Test_Routes(t *testing.T) { secretProvider := secretprovider.NewSecretProvider(secretprovider.SecretProviderOptions{}) secretProvider.SetClient(secretClient) - options := modules.Options{ - Address: "localhost", - PathBase: pathBase, - Config: &hostoptions.UCPConfig{}, + options := &ucp.Options{ + Config: &ucp.Config{ + Server: hostoptions.ServerOptions{ + Host: "localhost", + Port: 8080, + PathBase: pathBase, + }, + }, DatabaseProvider: databaseprovider.FromMemory(), SecretProvider: secretProvider, StatusManager: statusmanager.NewMockStatusManager(gomock.NewController(t)), diff --git a/pkg/ucp/frontend/controller/resourcegroups/util.go b/pkg/ucp/frontend/controller/resourcegroups/util.go index a1e012208f..6c9ab112a3 100644 --- a/pkg/ucp/frontend/controller/resourcegroups/util.go +++ b/pkg/ucp/frontend/controller/resourcegroups/util.go @@ -155,7 +155,10 @@ func ValidateResourceType(ctx context.Context, client database.Client, id resour // Resource types are case-insensitive so we have to iterate. var locationResourceType *datamodel.LocationResourceTypeConfiguration - // We special-case two pseudo-resource types: "locations/operationstatuses" and "locations/operationresults". + // We special-case two pseudo-resource types: "operationstatuses" and "operationresults". + // + // These are implemented by all resource providers, and don't require the resource provider to register them. + // // If the resource type is one of these, we can return the downstream URL directly. if isOperationResourceType(id) { locationResourceType = &datamodel.LocationResourceTypeConfiguration{ @@ -215,10 +218,11 @@ func isOperationResourceType(id resources.ID) bool { return true } - // An older pattern is to use a child resource + // An older pattern is to use a child resource, it might also use the name "operations" typeSegments := id.TypeSegments() if len(typeSegments) >= 2 && (strings.EqualFold(typeSegments[len(typeSegments)-1].Type, "operationstatuses") || - strings.EqualFold(typeSegments[len(typeSegments)-1].Type, "operationresults")) { + strings.EqualFold(typeSegments[len(typeSegments)-1].Type, "operationresults") || + strings.EqualFold(typeSegments[len(typeSegments)-1].Type, "operations")) { return true } diff --git a/pkg/ucp/frontend/controller/resourcegroups/util_test.go b/pkg/ucp/frontend/controller/resourcegroups/util_test.go index 3512294633..3b80d21bc3 100644 --- a/pkg/ucp/frontend/controller/resourcegroups/util_test.go +++ b/pkg/ucp/frontend/controller/resourcegroups/util_test.go @@ -107,138 +107,138 @@ func Test_ValidateDownstream(t *testing.T) { }, } - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeResource}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeResource}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) t.Run("success (non resource group)", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeResource}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeResource}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, idWithoutResourceGroup, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) // The deployment engine models its operation status resources as child resources of the deployment resource. t.Run("success (operationstatuses as child resource)", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) operationStatusID := resources.MustParse("/planes/radius/local/providers/System.TestRP/deployments/xzy/operationStatuses/abcd") expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, operationStatusID, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, operationStatusID, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) // All of the Radius RPs include a location in the operation status child resource. t.Run("success (operationstatuses with location)", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) operationStatusID := resources.MustParse("/planes/radius/local/providers/System.TestRP/locations/east/operationStatuses/abcd") expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, operationStatusID, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, operationStatusID, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) // The deployment engine models its operation result resources as child resources of the deployment resource. t.Run("success (operationresults as child resource)", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) operationResultID := resources.MustParse("/planes/radius/local/providers/System.TestRP/deployments/xzy/operationResults/abcd") expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, operationResultID, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, operationResultID, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) // All of the Radius RPs include a location in the operation result child resource. t.Run("success (operationresults with location)", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) operationResultID := resources.MustParse("/planes/radius/local/providers/System.TestRP/locations/east/operationResults/abcd") expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, operationResultID, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, operationResultID, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) t.Run("plane not found", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, &database.ErrNotFound{}).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, &database.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &NotFoundError{Message: "plane \"/planes/radius/local\" not found"}, err) require.Nil(t, downstreamURL) }) t.Run("plane retreival failure", func(t *testing.T) { - mock := setup(t) + databaseClient := setup(t) expected := fmt.Errorf("failed to fetch plane \"/planes/radius/local\": %w", errors.New("test error")) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, errors.New("test error")).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, expected, err) require.Nil(t, downstreamURL) }) t.Run("resource group not found", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, &database.ErrNotFound{}).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, &database.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &NotFoundError{Message: "resource group \"/planes/radius/local/resourceGroups/test-group\" not found"}, err) require.Nil(t, downstreamURL) }) t.Run("resource group err", func(t *testing.T) { - mock := setup(t) + databaseClient := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, errors.New("test error")).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, "failed to fetch resource group \"/planes/radius/local/resourceGroups/test-group\": test error", err.Error()) require.Nil(t, downstreamURL) @@ -255,12 +255,12 @@ func Test_ValidateDownstream(t *testing.T) { expected := fmt.Errorf("failed to fetch resource type %q: %w", "System.TestRP/testResources", errors.New("test error")) - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(nil, errors.New("test error")).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, expected, err) require.Nil(t, downstreamURL) @@ -277,13 +277,13 @@ func Test_ValidateDownstream(t *testing.T) { expected := fmt.Errorf("failed to fetch location %q: %w", locationResource.ID, errors.New("test error")) - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeID}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(nil, errors.New("test error")).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeID}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, expected, err) require.Nil(t, downstreamURL) @@ -317,13 +317,13 @@ func Test_ValidateDownstream(t *testing.T) { }, } - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeID}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeID}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "resource type \"System.TestRP/testResources\" not supported by location \"east\""}, err) require.Nil(t, downstreamURL) @@ -357,13 +357,13 @@ func Test_ValidateDownstream(t *testing.T) { }, } - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeID}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeID}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "api version \"2025-01-01\" is not supported for resource type \"System.TestRP/testResources\" by location \"east\""}, err) require.Nil(t, downstreamURL) @@ -397,13 +397,13 @@ func Test_ValidateDownstream(t *testing.T) { }, } - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeID}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeResource.ID).Return(&database.Object{Data: resourceTypeID}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), locationResource.ID).Return(&database.Object{Data: locationResource}, nil).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "failed to parse location address: parse \"\\ninvalid\": net/url: invalid control character in URL"}, err) require.Nil(t, downstreamURL) @@ -451,72 +451,72 @@ func Test_ValidateDownstream_Legacy(t *testing.T) { }, } - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) t.Run("success (non resource group)", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), idWithoutResourceGroup.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) expectedURL, err := url.Parse(downstream) require.NoError(t, err) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, idWithoutResourceGroup, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, idWithoutResourceGroup, location, apiVersion) require.NoError(t, err) require.Equal(t, expectedURL, downstreamURL) }) t.Run("plane not found", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, &database.ErrNotFound{}).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, &database.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &NotFoundError{Message: "plane \"/planes/radius/local\" not found"}, err) require.Nil(t, downstreamURL) }) t.Run("plane retrieval failure", func(t *testing.T) { - mock := setup(t) + databaseClient := setup(t) expected := fmt.Errorf("failed to fetch plane \"/planes/radius/local\": %w", errors.New("test error")) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, errors.New("test error")).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, expected, err) require.Nil(t, downstreamURL) }) t.Run("resource group not found", func(t *testing.T) { - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, &database.ErrNotFound{}).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, &database.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &NotFoundError{Message: "resource group \"/planes/radius/local/resourceGroups/test-group\" not found"}, err) require.Nil(t, downstreamURL) }) t.Run("resource group err", func(t *testing.T) { - mock := setup(t) + databaseClient := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, errors.New("test error")).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(nil, errors.New("test error")).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, "failed to fetch resource group \"/planes/radius/local/resourceGroups/test-group\": test error", err.Error()) require.Nil(t, downstreamURL) @@ -542,12 +542,12 @@ func Test_ValidateDownstream_Legacy(t *testing.T) { }, } - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "resource provider System.TestRP not configured"}, err) require.Nil(t, downstreamURL) @@ -573,12 +573,12 @@ func Test_ValidateDownstream_Legacy(t *testing.T) { }, } - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "resource provider System.TestRP not configured"}, err) require.Nil(t, downstreamURL) @@ -606,12 +606,12 @@ func Test_ValidateDownstream_Legacy(t *testing.T) { }, } - mock := setup(t) - mock.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) - mock.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) + databaseClient := setup(t) + databaseClient.EXPECT().Get(gomock.Any(), id.PlaneScope()).Return(&database.Object{Data: plane}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), id.RootScope()).Return(&database.Object{Data: resourceGroup}, nil).Times(1) + databaseClient.EXPECT().Get(gomock.Any(), resourceTypeID.String()).Return(nil, &database.ErrNotFound{}).Times(1) - downstreamURL, err := ValidateDownstream(testcontext.New(t), mock, id, location, apiVersion) + downstreamURL, err := ValidateDownstream(testcontext.New(t), databaseClient, id, location, apiVersion) require.Error(t, err) require.Equal(t, &InvalidError{Message: "failed to parse downstream URL: parse \"\\ninvalid\": net/url: invalid control character in URL"}, err) require.Nil(t, downstreamURL) diff --git a/pkg/ucp/frontend/modules/types.go b/pkg/ucp/frontend/modules/types.go index 3e1638302f..6b93a359d6 100644 --- a/pkg/ucp/frontend/modules/types.go +++ b/pkg/ucp/frontend/modules/types.go @@ -19,14 +19,6 @@ package modules import ( "context" "net/http" - - "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" - "github.com/radius-project/radius/pkg/components/database/databaseprovider" - "github.com/radius-project/radius/pkg/components/queue/queueprovider" - "github.com/radius-project/radius/pkg/components/secret/secretprovider" - "github.com/radius-project/radius/pkg/sdk" - "github.com/radius-project/radius/pkg/ucp/hostoptions" - "github.com/radius-project/radius/pkg/validator" ) // Initializer is an interface that can be implemented by modules that want to provide functionality for a plane. @@ -45,36 +37,3 @@ type Initializer interface { // - radius PlaneType() string } - -// Options defines the options for a module. -type Options struct { - // Config is the bootstrap configuration loaded from config file. - Config *hostoptions.UCPConfig - - // Address is the hostname + port of the server hosting UCP. - Address string - - // PathBase is the base path of the server as it appears in the URL. eg: '/apis/api.ucp.dev/v1alpha3'. - PathBase string - - // Location is the location of the server hosting UCP. - Location string - - // DatabaseProvider is the database provider. - DatabaseProvider *databaseprovider.DatabaseProvider - - // QeueueProvider provides access to the queue for async operations. - QueueProvider *queueprovider.QueueProvider - - // SecretProvider is the secret store provider used for managing credentials. - SecretProvider *secretprovider.SecretProvider - - // SpecLoader is the OpenAPI spec loader containing specs for the UCP APIs. - SpecLoader *validator.Loader - - // StatusManager is the async operation status manager. - StatusManager statusmanager.StatusManager - - // UCPConnection is the connection used to communicate with UCP APIs. - UCPConnection sdk.Connection -} diff --git a/pkg/ucp/frontend/radius/module.go b/pkg/ucp/frontend/radius/module.go index 811e5692d4..6b2a867429 100644 --- a/pkg/ucp/frontend/radius/module.go +++ b/pkg/ucp/frontend/radius/module.go @@ -18,12 +18,13 @@ package radius import ( "github.com/go-chi/chi/v5" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/frontend/modules" "github.com/radius-project/radius/pkg/validator" ) // NewModule creates a new Radius module. -func NewModule(options modules.Options) *Module { +func NewModule(options *ucp.Options) *Module { router := chi.NewRouter() router.NotFound(validator.APINotFoundHandler()) router.MethodNotAllowed(validator.APIMethodNotAllowedHandler()) @@ -35,7 +36,7 @@ var _ modules.Initializer = &Module{} // Module defines the module for Radius functionality. type Module struct { - options modules.Options + options *ucp.Options router chi.Router defaultDownstream string } diff --git a/pkg/ucp/frontend/radius/routes.go b/pkg/ucp/frontend/radius/routes.go index 951a49af3a..84576da55b 100644 --- a/pkg/ucp/frontend/radius/routes.go +++ b/pkg/ucp/frontend/radius/routes.go @@ -70,14 +70,17 @@ func (m *Module) Initialize(ctx context.Context) (http.Handler, error) { } ctrlOptions := controller.Options{ - Address: m.options.Address, - PathBase: m.options.PathBase, + Address: m.options.Config.Server.Address(), DatabaseClient: databaseClient, + PathBase: m.options.Config.Server.PathBase, StatusManager: m.options.StatusManager, + + KubeClient: nil, // Unused by Radius module + ResourceType: "", // Set dynamically } // NOTE: we're careful where we use the `apiValidator` middleware. It's not used for the proxy routes. - m.router.Route(m.options.PathBase+"/planes/radius", func(r chi.Router) { + m.router.Route(m.options.Config.Server.PathBase+"/planes/radius", func(r chi.Router) { r.With(apiValidator).Get("/", capture(radiusPlaneListHandler(ctx, ctrlOptions))) r.Route("/{planeName}", func(r chi.Router) { r.With(apiValidator).Get("/", capture(radiusPlaneGetHandler(ctx, ctrlOptions))) diff --git a/pkg/ucp/frontend/radius/routes_test.go b/pkg/ucp/frontend/radius/routes_test.go index 8bca6bf56f..813d33ab6d 100644 --- a/pkg/ucp/frontend/radius/routes_test.go +++ b/pkg/ucp/frontend/radius/routes_test.go @@ -23,14 +23,14 @@ import ( "github.com/go-chi/chi/v5" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/armrpc/hostoptions" "github.com/radius-project/radius/pkg/armrpc/rpctest" "github.com/radius-project/radius/pkg/components/database/databaseprovider" "github.com/radius-project/radius/pkg/components/secret" "github.com/radius-project/radius/pkg/components/secret/secretprovider" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/datamodel" - "github.com/radius-project/radius/pkg/ucp/frontend/modules" - "github.com/radius-project/radius/pkg/ucp/hostoptions" "go.uber.org/mock/gomock" ) @@ -191,10 +191,14 @@ func Test_Routes(t *testing.T) { secretProvider := secretprovider.NewSecretProvider(secretprovider.SecretProviderOptions{}) secretProvider.SetClient(secretClient) - options := modules.Options{ - Address: "localhost", - PathBase: pathBase, - Config: &hostoptions.UCPConfig{}, + options := &ucp.Options{ + Config: &ucp.Config{ + Server: hostoptions.ServerOptions{ + Host: "localhost", + Port: 8080, + PathBase: pathBase, + }, + }, DatabaseProvider: databaseProvider, SecretProvider: secretProvider, } diff --git a/pkg/ucp/hostoptions/hostoptions.go b/pkg/ucp/hostoptions/hostoptions.go deleted file mode 100644 index db708f73b5..0000000000 --- a/pkg/ucp/hostoptions/hostoptions.go +++ /dev/null @@ -1,64 +0,0 @@ -/* -Copyright 2023 The Radius Authors. - -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. -*/ - -// hostoptions defines and reads options for the RP's execution environment. - -package hostoptions - -import ( - "bytes" - "fmt" - "os" - - "gopkg.in/yaml.v3" -) - -// HostOptions defines all of the settings that our RP's execution environment provides. -type HostOptions struct { - // Config is the bootstrap configuration loaded from config file. - Config *UCPConfig -} - -// NewHostOptionsFromEnvironment reads the configuration from the given path and returns a HostOptions object, or an -// error if the configuration could not be loaded. -func NewHostOptionsFromEnvironment(configPath string) (HostOptions, error) { - conf, err := loadConfig(configPath) - if err != nil { - return HostOptions{}, err - } - - return HostOptions{ - Config: conf, - }, nil -} - -func loadConfig(configPath string) (*UCPConfig, error) { - buf, err := os.ReadFile(configPath) - if err != nil { - return nil, err - } - - conf := &UCPConfig{} - decoder := yaml.NewDecoder(bytes.NewBuffer(buf)) - decoder.KnownFields(true) - - err = decoder.Decode(conf) - if err != nil { - return nil, fmt.Errorf("failed to load yaml: %w", err) - } - - return conf, nil -} diff --git a/pkg/ucp/hostoptions/providerconfig.go b/pkg/ucp/hostoptions/providerconfig.go deleted file mode 100644 index 30729336f0..0000000000 --- a/pkg/ucp/hostoptions/providerconfig.go +++ /dev/null @@ -1,65 +0,0 @@ -/* -Copyright 2023 The Radius Authors. - -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 hostoptions - -import ( - "github.com/radius-project/radius/pkg/components/database/databaseprovider" - "github.com/radius-project/radius/pkg/components/queue/queueprovider" - "github.com/radius-project/radius/pkg/components/secret/secretprovider" - metricsprovider "github.com/radius-project/radius/pkg/metrics/provider" - profilerprovider "github.com/radius-project/radius/pkg/profiler/provider" - "github.com/radius-project/radius/pkg/trace" - "github.com/radius-project/radius/pkg/ucp/config" - "github.com/radius-project/radius/pkg/ucp/rest" - "github.com/radius-project/radius/pkg/ucp/ucplog" -) - -// UCPConfig includes the resource provider configuration. -type UCPConfig struct { - DatabaseProvider databaseprovider.Options `yaml:"storageProvider"` - Planes []rest.Plane `yaml:"planes"` - SecretProvider secretprovider.SecretProviderOptions `yaml:"secretProvider"` - MetricsProvider metricsprovider.MetricsProviderOptions `yaml:"metricsProvider"` - ProfilerProvider profilerprovider.ProfilerProviderOptions `yaml:"profilerProvider"` - QueueProvider queueprovider.QueueProviderOptions `yaml:"queueProvider"` - TracerProvider trace.Options `yaml:"tracerProvider"` - Logging ucplog.LoggingOptions `yaml:"logging"` - Identity Identity `yaml:"identity,omitempty"` - UCP config.UCPOptions `yaml:"ucp"` - Location string `yaml:"location"` - Routing RoutingConfig `yaml:"routing"` -} - -const ( - // AuthUCPCredential is the authentication method via UCP Credential API. - AuthUCPCredential = "UCPCredential" - // AuthDefault is the default authentication method, such as environment variables. - AuthDefault = "default" -) - -// Identity represents configuration options for authenticating with external systems like Azure and AWS. -type Identity struct { - // AuthMethod represents the method of authentication for authenticating with external systems like Azure and AWS. - AuthMethod string `yaml:"authMethod"` -} - -// RoutingConfig provides configuration for UCP routing. -type RoutingConfig struct { - // DefaultDownstreamEndpoint is the default destination when a resource provider does not provide a downstream endpoint. - // In practice, this points to the URL of dynamic-rp. - DefaultDownstreamEndpoint string `yaml:"defaultDownstreamEndpoint"` -} diff --git a/pkg/ucp/integrationtests/aws/awstest.go b/pkg/ucp/integrationtests/aws/awstest.go index bca5b13119..59e77df5cf 100644 --- a/pkg/ucp/integrationtests/aws/awstest.go +++ b/pkg/ucp/integrationtests/aws/awstest.go @@ -21,12 +21,11 @@ package aws import ( "testing" - "github.com/radius-project/radius/pkg/components/database" - "github.com/radius-project/radius/pkg/components/secret" + "github.com/radius-project/radius/pkg/ucp" ucp_aws "github.com/radius-project/radius/pkg/ucp/aws" ucp_aws_frontend "github.com/radius-project/radius/pkg/ucp/frontend/aws" "github.com/radius-project/radius/pkg/ucp/frontend/modules" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "go.uber.org/mock/gomock" ) @@ -38,17 +37,17 @@ const ( testAWSRequestToken = "79B9F0DA-4882-4DC8-A367-6FD3BC122DED" // Random UUID ) -func initializeAWSTest(t *testing.T) (*testserver.TestServer, *database.MockClient, *secret.MockClient, *ucp_aws.MockAWSCloudControlClient, *ucp_aws.MockAWSCloudFormationClient) { +func initializeAWSTest(t *testing.T) (*testhost.TestHost, *ucp_aws.MockAWSCloudControlClient, *ucp_aws.MockAWSCloudFormationClient) { ctrl := gomock.NewController(t) cloudControlClient := ucp_aws.NewMockAWSCloudControlClient(ctrl) cloudFormationClient := ucp_aws.NewMockAWSCloudFormationClient(ctrl) - ucp := testserver.StartWithMocks(t, func(options modules.Options) []modules.Initializer { + ucp := testhost.Start(t, testhost.TestHostOptionFunc(func(options *ucp.Options) { module := ucp_aws_frontend.NewModule(options) module.AWSClients.CloudControl = cloudControlClient module.AWSClients.CloudFormation = cloudFormationClient - return []modules.Initializer{module} - }) + options.Modules = []modules.Initializer{module} + })) - return ucp, ucp.Mocks.Database, ucp.Mocks.Secrets, cloudControlClient, cloudFormationClient + return ucp, cloudControlClient, cloudFormationClient } diff --git a/pkg/ucp/integrationtests/aws/createresource_test.go b/pkg/ucp/integrationtests/aws/createresource_test.go index 78475c40a8..055f753b52 100644 --- a/pkg/ucp/integrationtests/aws/createresource_test.go +++ b/pkg/ucp/integrationtests/aws/createresource_test.go @@ -35,7 +35,7 @@ import ( ) func Test_CreateAWSResource(t *testing.T) { - ucp, _, _, cloudcontrolClient, _ := initializeAWSTest(t) + ucp, cloudcontrolClient, _ := initializeAWSTest(t) cloudcontrolClient.EXPECT().GetResource(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *cloudcontrol.GetResourceInput, optFns ...func(*cloudcontrol.Options)) (*cloudcontrol.GetResourceOutput, error) { notfound := types.ResourceNotFoundException{ @@ -63,7 +63,7 @@ func Test_CreateAWSResource(t *testing.T) { body, err := json.Marshal(requestBody) require.NoError(t, err) - createRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPut, ucp.BaseURL+testProxyRequestAWSPath, body) + createRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPut, ucp.BaseURL()+testProxyRequestAWSPath, body) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(createRequest) diff --git a/pkg/ucp/integrationtests/aws/createresourcewithpost_test.go b/pkg/ucp/integrationtests/aws/createresourcewithpost_test.go index 4909caa092..19d96f852a 100644 --- a/pkg/ucp/integrationtests/aws/createresourcewithpost_test.go +++ b/pkg/ucp/integrationtests/aws/createresourcewithpost_test.go @@ -37,7 +37,7 @@ import ( ) func Test_CreateAWSResourceWithPost(t *testing.T) { - ucp, _, _, cloudcontrolClient, cloudformationClient := initializeAWSTest(t) + ucp, cloudcontrolClient, cloudformationClient := initializeAWSTest(t) primaryIdentifiers := map[string]any{ "primaryIdentifier": []any{ @@ -81,7 +81,7 @@ func Test_CreateAWSResourceWithPost(t *testing.T) { body, err := json.Marshal(requestBody) require.NoError(t, err) - createRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPost, ucp.BaseURL+testProxyRequestAWSCollectionPath+"/:put", body) + createRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPost, ucp.BaseURL()+testProxyRequestAWSCollectionPath+"/:put", body) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(createRequest) diff --git a/pkg/ucp/integrationtests/aws/deleteresource_test.go b/pkg/ucp/integrationtests/aws/deleteresource_test.go index f497772f76..5edca23c39 100644 --- a/pkg/ucp/integrationtests/aws/deleteresource_test.go +++ b/pkg/ucp/integrationtests/aws/deleteresource_test.go @@ -34,7 +34,7 @@ import ( ) func Test_DeleteAWSResource(t *testing.T) { - ucp, _, _, cloudcontrolClient, _ := initializeAWSTest(t) + ucp, cloudcontrolClient, _ := initializeAWSTest(t) cloudcontrolClient.EXPECT().DeleteResource(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *cloudcontrol.DeleteResourceInput, optFns ...func(*cloudcontrol.Options)) (*cloudcontrol.DeleteResourceOutput, error) { output := cloudcontrol.DeleteResourceOutput{ @@ -46,7 +46,7 @@ func Test_DeleteAWSResource(t *testing.T) { return &output, nil }) - deleteRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodDelete, ucp.BaseURL+testProxyRequestAWSPath, nil) + deleteRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodDelete, ucp.BaseURL()+testProxyRequestAWSPath, nil) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(deleteRequest) diff --git a/pkg/ucp/integrationtests/aws/deleteresourcewithpost_test.go b/pkg/ucp/integrationtests/aws/deleteresourcewithpost_test.go index fc78fb54a4..f9f5078d05 100644 --- a/pkg/ucp/integrationtests/aws/deleteresourcewithpost_test.go +++ b/pkg/ucp/integrationtests/aws/deleteresourcewithpost_test.go @@ -37,7 +37,7 @@ import ( ) func Test_DeleteAWSResourceWithPost(t *testing.T) { - ucp, _, _, cloudcontrolClient, cloudformationClient := initializeAWSTest(t) + ucp, cloudcontrolClient, cloudformationClient := initializeAWSTest(t) primaryIdentifiers := map[string]any{ "primaryIdentifier": []any{ @@ -73,7 +73,7 @@ func Test_DeleteAWSResourceWithPost(t *testing.T) { body, err := json.Marshal(requestBody) require.NoError(t, err) - deleteRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPost, ucp.BaseURL+testProxyRequestAWSCollectionPath+"/:delete", body) + deleteRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPost, ucp.BaseURL()+testProxyRequestAWSCollectionPath+"/:delete", body) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(deleteRequest) diff --git a/pkg/ucp/integrationtests/aws/getresource_test.go b/pkg/ucp/integrationtests/aws/getresource_test.go index c9e17c38d9..501be7565f 100644 --- a/pkg/ucp/integrationtests/aws/getresource_test.go +++ b/pkg/ucp/integrationtests/aws/getresource_test.go @@ -35,7 +35,7 @@ import ( ) func Test_GetAWSResource(t *testing.T) { - ucp, _, _, cloudcontrolClient, _ := initializeAWSTest(t) + ucp, cloudcontrolClient, _ := initializeAWSTest(t) getResponseBody := map[string]any{ "RetentionPeriodHours": 178, @@ -54,7 +54,7 @@ func Test_GetAWSResource(t *testing.T) { return &output, nil }) - getRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodGet, ucp.BaseURL+testProxyRequestAWSPath, nil) + getRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodGet, ucp.BaseURL()+testProxyRequestAWSPath, nil) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(getRequest) diff --git a/pkg/ucp/integrationtests/aws/getresourcewithpost_test.go b/pkg/ucp/integrationtests/aws/getresourcewithpost_test.go index 95d72ee5c6..e33a972e15 100644 --- a/pkg/ucp/integrationtests/aws/getresourcewithpost_test.go +++ b/pkg/ucp/integrationtests/aws/getresourcewithpost_test.go @@ -37,7 +37,7 @@ import ( ) func Test_GetAWSResourceWithPost(t *testing.T) { - ucp, _, _, cloudcontrolClient, cloudformationClient := initializeAWSTest(t) + ucp, cloudcontrolClient, cloudformationClient := initializeAWSTest(t) primaryIdentifiers := map[string]any{ "primaryIdentifier": []any{ @@ -80,7 +80,7 @@ func Test_GetAWSResourceWithPost(t *testing.T) { body, err := json.Marshal(requestBody) require.NoError(t, err) - getRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPost, ucp.BaseURL+testProxyRequestAWSCollectionPath+"/:get", body) + getRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPost, ucp.BaseURL()+testProxyRequestAWSCollectionPath+"/:get", body) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(getRequest) diff --git a/pkg/ucp/integrationtests/aws/listresources_test.go b/pkg/ucp/integrationtests/aws/listresources_test.go index ca97d8ba9a..c068073a4f 100644 --- a/pkg/ucp/integrationtests/aws/listresources_test.go +++ b/pkg/ucp/integrationtests/aws/listresources_test.go @@ -37,7 +37,7 @@ import ( const testProxyRequestAWSListPath = "/planes/aws/aws/accounts/1234567/regions/us-east-1/providers/AWS.Kinesis/Stream" func Test_ListAWSResources(t *testing.T) { - ucp, _, _, cloudcontrolClient, _ := initializeAWSTest(t) + ucp, cloudcontrolClient, _ := initializeAWSTest(t) getResponseBody := map[string]any{ "RetentionPeriodHours": 178, @@ -58,7 +58,7 @@ func Test_ListAWSResources(t *testing.T) { return &output, nil }) - listRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodGet, ucp.BaseURL+testProxyRequestAWSListPath, nil) + listRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodGet, ucp.BaseURL()+testProxyRequestAWSListPath, nil) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(listRequest) diff --git a/pkg/ucp/integrationtests/aws/operationresults_test.go b/pkg/ucp/integrationtests/aws/operationresults_test.go index 354c79c44b..730480bf71 100644 --- a/pkg/ucp/integrationtests/aws/operationresults_test.go +++ b/pkg/ucp/integrationtests/aws/operationresults_test.go @@ -35,7 +35,7 @@ import ( ) func Test_GetOperationResults(t *testing.T) { - ucp, _, _, cloudcontrolClient, _ := initializeAWSTest(t) + ucp, cloudcontrolClient, _ := initializeAWSTest(t) cloudcontrolClient.EXPECT().GetResourceRequestStatus(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *cloudcontrol.GetResourceRequestStatusInput, optFns ...func(*cloudcontrol.Options)) (*cloudcontrol.GetResourceRequestStatusOutput, error) { output := cloudcontrol.GetResourceRequestStatusOutput{ @@ -46,7 +46,7 @@ func Test_GetOperationResults(t *testing.T) { return &output, nil }) - operationResultsRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodGet, ucp.BaseURL+testProxyRequestAWSAsyncPath+"/operationResults/"+strings.ToLower(testAWSRequestToken), nil) + operationResultsRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodGet, ucp.BaseURL()+testProxyRequestAWSAsyncPath+"/operationResults/"+strings.ToLower(testAWSRequestToken), nil) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(operationResultsRequest) diff --git a/pkg/ucp/integrationtests/aws/operationstatuses_test.go b/pkg/ucp/integrationtests/aws/operationstatuses_test.go index 1de5766732..ae78037c6b 100644 --- a/pkg/ucp/integrationtests/aws/operationstatuses_test.go +++ b/pkg/ucp/integrationtests/aws/operationstatuses_test.go @@ -36,7 +36,7 @@ import ( ) func Test_GetOperationStatuses(t *testing.T) { - ucp, _, _, cloudcontrolClient, _ := initializeAWSTest(t) + ucp, cloudcontrolClient, _ := initializeAWSTest(t) cloudcontrolClient.EXPECT().GetResourceRequestStatus(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *cloudcontrol.GetResourceRequestStatusInput, optFns ...func(*cloudcontrol.Options)) (*cloudcontrol.GetResourceRequestStatusOutput, error) { output := cloudcontrol.GetResourceRequestStatusOutput{ @@ -48,7 +48,7 @@ func Test_GetOperationStatuses(t *testing.T) { return &output, nil }) - operationResultsRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodGet, ucp.BaseURL+testProxyRequestAWSAsyncPath+"/operationStatuses/"+strings.ToLower(testAWSRequestToken), nil) + operationResultsRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodGet, ucp.BaseURL()+testProxyRequestAWSAsyncPath+"/operationStatuses/"+strings.ToLower(testAWSRequestToken), nil) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(operationResultsRequest) diff --git a/pkg/ucp/integrationtests/aws/updateresource_test.go b/pkg/ucp/integrationtests/aws/updateresource_test.go index 6afdcc7138..3f5b9d1a48 100644 --- a/pkg/ucp/integrationtests/aws/updateresource_test.go +++ b/pkg/ucp/integrationtests/aws/updateresource_test.go @@ -39,7 +39,7 @@ import ( const ZeroAWSRequestToken = "00000000-0000-0000-0000-000000000000" func Test_UpdateAWSResource(t *testing.T) { - ucp, _, _, cloudcontrolClient, cloudFormationClient := initializeAWSTest(t) + ucp, cloudcontrolClient, cloudFormationClient := initializeAWSTest(t) getResponseBody := map[string]any{ "RetentionPeriodHours": 178, @@ -94,7 +94,7 @@ func Test_UpdateAWSResource(t *testing.T) { body, err := json.Marshal(requestBody) require.NoError(t, err) - updateRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPut, ucp.BaseURL+testProxyRequestAWSPath, body) + updateRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPut, ucp.BaseURL()+testProxyRequestAWSPath, body) require.NoError(t, err, "creating request failed") ctx := rpctest.NewARMRequestContext(updateRequest) diff --git a/pkg/ucp/integrationtests/aws/updateresourcewithpost_test.go b/pkg/ucp/integrationtests/aws/updateresourcewithpost_test.go index 015074783d..8c23b7d1a0 100644 --- a/pkg/ucp/integrationtests/aws/updateresourcewithpost_test.go +++ b/pkg/ucp/integrationtests/aws/updateresourcewithpost_test.go @@ -37,7 +37,7 @@ import ( ) func Test_UpdateAWSResourceWithPost(t *testing.T) { - ucp, _, _, cloudcontrolClient, cloudformationClient := initializeAWSTest(t) + ucp, cloudcontrolClient, cloudformationClient := initializeAWSTest(t) primaryIdentifiers := map[string]any{ "primaryIdentifier": []any{ @@ -90,7 +90,7 @@ func Test_UpdateAWSResourceWithPost(t *testing.T) { body, err := json.Marshal(requestBody) require.NoError(t, err) - updateRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPost, ucp.BaseURL+testProxyRequestAWSCollectionPath+"/:put", body) + updateRequest, err := rpctest.NewHTTPRequestWithContent(context.Background(), http.MethodPost, ucp.BaseURL()+testProxyRequestAWSCollectionPath+"/:put", body) require.NoError(t, err, "update request failed") ctx := rpctest.NewARMRequestContext(updateRequest) diff --git a/pkg/ucp/integrationtests/azure/proxy_test.go b/pkg/ucp/integrationtests/azure/proxy_test.go index 74261d45f4..d672c83168 100644 --- a/pkg/ucp/integrationtests/azure/proxy_test.go +++ b/pkg/ucp/integrationtests/azure/proxy_test.go @@ -25,9 +25,8 @@ import ( v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" - "github.com/radius-project/radius/pkg/ucp/frontend/api" "github.com/radius-project/radius/pkg/ucp/integrationtests/testrp" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/require" ) @@ -38,7 +37,7 @@ const ( ) func Test_AzurePlane_ProxyRequest(t *testing.T) { - ucp := testserver.StartWithETCD(t, api.DefaultModules) + ucp := testhost.Start(t) data := map[string]string{ "message": "here is some test data", @@ -63,7 +62,7 @@ func Test_AzurePlane_ProxyRequest(t *testing.T) { require.Equal(t, "SomeValue", response.Raw.Header.Get("SomeHeader")) } -func createAzurePlane(ucp *testserver.TestServer, rp *testrp.Server) { +func createAzurePlane(ucp *testhost.TestHost, rp *testrp.Server) { body := v20231001preview.AzurePlaneResource{ Location: to.Ptr(v1.LocationGlobal), Properties: &v20231001preview.AzurePlaneResourceProperties{ diff --git a/pkg/ucp/integrationtests/handler_test.go b/pkg/ucp/integrationtests/handler_test.go index c131536c73..b8c0ee2a40 100644 --- a/pkg/ucp/integrationtests/handler_test.go +++ b/pkg/ucp/integrationtests/handler_test.go @@ -20,20 +20,32 @@ import ( "net/http" "testing" + "github.com/google/uuid" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/require" ) func Test_Handler_MethodNotAllowed(t *testing.T) { - ucp := testserver.StartWithMocks(t, testserver.NoModules) + ucp := testhost.Start(t, testhost.NoModules()) response := ucp.MakeRequest(http.MethodDelete, "/planes?api-version=2023-10-01-preview", nil) require.Equal(t, "failed to parse route: undefined route path", response.Error.Error.Details[0].Message) } func Test_Handler_NotFound(t *testing.T) { - ucp := testserver.StartWithMocks(t, testserver.NoModules) + ucp := testhost.Start(t, testhost.NoModules()) + + response := ucp.MakeRequest(http.MethodGet, "/abc", nil) + response.EqualsErrorCode(http.StatusNotFound, v1.CodeNotFound) + require.Regexp(t, "The request 'GET /abc' is invalid.", response.Error.Error.Message) +} + +func Test_Handler_NotFound_PathBase(t *testing.T) { + ucp := testhost.Start(t, testhost.NoModules(), testhost.TestHostOptionFunc(func(options *ucp.Options) { + options.Config.Server.PathBase = "/" + uuid.New().String() + })) response := ucp.MakeRequest(http.MethodGet, "/abc", nil) response.EqualsErrorCode(http.StatusNotFound, v1.CodeNotFound) diff --git a/pkg/ucp/integrationtests/planes/aws_test.go b/pkg/ucp/integrationtests/planes/aws_test.go index fa707bf3c8..b810b648cf 100644 --- a/pkg/ucp/integrationtests/planes/aws_test.go +++ b/pkg/ucp/integrationtests/planes/aws_test.go @@ -20,8 +20,7 @@ import ( "testing" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" ) const ( @@ -35,7 +34,7 @@ const ( ) func Test_AWSPlane_PUT_Create(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", awsPlaneResourceURL, awsPlaneRequestFixture) @@ -43,7 +42,7 @@ func Test_AWSPlane_PUT_Create(t *testing.T) { } func Test_AWSPlane_PUT_Update(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", awsPlaneResourceURL, awsPlaneRequestFixture) @@ -54,7 +53,7 @@ func Test_AWSPlane_PUT_Update(t *testing.T) { } func Test_AWSPlane_GET_Empty(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeRequest("GET", awsPlaneResourceURL, nil) @@ -62,7 +61,7 @@ func Test_AWSPlane_GET_Empty(t *testing.T) { } func Test_AWSPlane_GET_Found(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", awsPlaneResourceURL, awsPlaneRequestFixture) @@ -73,8 +72,7 @@ func Test_AWSPlane_GET_Found(t *testing.T) { } func Test_AWSPlane_LIST(t *testing.T) { - - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", awsPlaneResourceURL, awsPlaneRequestFixture) @@ -85,7 +83,7 @@ func Test_AWSPlane_LIST(t *testing.T) { } func Test_AWSPlane_DELETE_DoesNotExist(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeRequest("DELETE", awsPlaneResourceURL, nil) @@ -93,7 +91,7 @@ func Test_AWSPlane_DELETE_DoesNotExist(t *testing.T) { } func Test_AWSPlane_DELETE_Found(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", awsPlaneResourceURL, awsPlaneRequestFixture) diff --git a/pkg/ucp/integrationtests/planes/azure_test.go b/pkg/ucp/integrationtests/planes/azure_test.go index 5ce199c9f0..49e9cf4b1f 100644 --- a/pkg/ucp/integrationtests/planes/azure_test.go +++ b/pkg/ucp/integrationtests/planes/azure_test.go @@ -20,8 +20,7 @@ import ( "testing" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" ) const ( @@ -35,7 +34,7 @@ const ( ) func Test_AzurePlane_PUT_Create(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", azurePlaneResourceURL, azurePlaneRequestFixture) @@ -43,7 +42,7 @@ func Test_AzurePlane_PUT_Create(t *testing.T) { } func Test_AzurePlane_PUT_Update(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", azurePlaneResourceURL, azurePlaneRequestFixture) @@ -54,7 +53,7 @@ func Test_AzurePlane_PUT_Update(t *testing.T) { } func Test_AzurePlane_GET_Empty(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeRequest("GET", azurePlaneResourceURL, nil) @@ -62,7 +61,7 @@ func Test_AzurePlane_GET_Empty(t *testing.T) { } func Test_AzurePlane_GET_Found(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", azurePlaneResourceURL, azurePlaneRequestFixture) @@ -73,7 +72,7 @@ func Test_AzurePlane_GET_Found(t *testing.T) { } func Test_AzurePlane_LIST(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() // Add a azure plane @@ -86,7 +85,7 @@ func Test_AzurePlane_LIST(t *testing.T) { } func Test_AzurePlane_DELETE_DoesNotExist(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeRequest("DELETE", azurePlaneResourceURL, nil) @@ -94,7 +93,7 @@ func Test_AzurePlane_DELETE_DoesNotExist(t *testing.T) { } func Test_AzurePlane_DELETE_Found(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", azurePlaneResourceURL, azurePlaneRequestFixture) diff --git a/pkg/ucp/integrationtests/planes/planes_test.go b/pkg/ucp/integrationtests/planes/planes_test.go index 2a6eb9c35e..03dbd1baad 100644 --- a/pkg/ucp/integrationtests/planes/planes_test.go +++ b/pkg/ucp/integrationtests/planes/planes_test.go @@ -19,8 +19,7 @@ package planes import ( "testing" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" ) const ( @@ -30,7 +29,7 @@ const ( ) func Test_AllPlanes_LIST(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", radiusPlaneResourceURL, radiusPlaneRequestFixture) diff --git a/pkg/ucp/integrationtests/planes/radius_test.go b/pkg/ucp/integrationtests/planes/radius_test.go index d2e8fbfa69..667e4a621c 100644 --- a/pkg/ucp/integrationtests/planes/radius_test.go +++ b/pkg/ucp/integrationtests/planes/radius_test.go @@ -20,8 +20,7 @@ import ( "testing" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" ) const ( @@ -35,7 +34,7 @@ const ( ) func Test_RadiusPlane_PUT_Create(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", radiusPlaneResourceURL, radiusPlaneRequestFixture) @@ -43,7 +42,7 @@ func Test_RadiusPlane_PUT_Create(t *testing.T) { } func Test_RadiusPlane_PUT_Update(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", radiusPlaneResourceURL, radiusPlaneRequestFixture) @@ -54,7 +53,7 @@ func Test_RadiusPlane_PUT_Update(t *testing.T) { } func Test_RadiusPlane_GET_Empty(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeRequest("GET", radiusPlaneResourceURL, nil) @@ -62,7 +61,7 @@ func Test_RadiusPlane_GET_Empty(t *testing.T) { } func Test_RadiusPlane_GET_Found(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", radiusPlaneResourceURL, radiusPlaneRequestFixture) @@ -73,7 +72,7 @@ func Test_RadiusPlane_GET_Found(t *testing.T) { } func Test_RadiusPlane_LIST(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() // Add a radius plane @@ -86,7 +85,7 @@ func Test_RadiusPlane_LIST(t *testing.T) { } func Test_RadiusPlane_DELETE_DoesNotExist(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeRequest("DELETE", radiusPlaneResourceURL, nil) @@ -94,7 +93,7 @@ func Test_RadiusPlane_DELETE_DoesNotExist(t *testing.T) { } func Test_RadiusPlane_DELETE_Found(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() response := server.MakeFixtureRequest("PUT", radiusPlaneResourceURL, radiusPlaneRequestFixture) diff --git a/pkg/ucp/integrationtests/planes/validation_test.go b/pkg/ucp/integrationtests/planes/validation_test.go index 015f49b12f..e6607c45c0 100644 --- a/pkg/ucp/integrationtests/planes/validation_test.go +++ b/pkg/ucp/integrationtests/planes/validation_test.go @@ -23,8 +23,7 @@ import ( v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/require" ) @@ -33,7 +32,7 @@ const ( ) func Test_Planes_GET_BadAPIVersion(t *testing.T) { - ucp := testserver.StartWithMocks(t, api.DefaultModules) + ucp := testhost.Start(t) response := ucp.MakeRequest(http.MethodGet, "/planes?api-version=unsupported-version", nil) response.EqualsErrorCode(http.StatusBadRequest, v1.CodeInvalidApiVersionParameter) @@ -41,7 +40,7 @@ func Test_Planes_GET_BadAPIVersion(t *testing.T) { } func Test_Planes_PUT_BadAPIVersion(t *testing.T) { - ucp := testserver.StartWithMocks(t, api.DefaultModules) + ucp := testhost.Start(t) requestBody := v20231001preview.RadiusPlaneResource{ Location: to.Ptr(v1.LocationGlobal), diff --git a/pkg/ucp/integrationtests/radius/proxy_test.go b/pkg/ucp/integrationtests/radius/proxy_test.go index 4b96eb7229..5275034f31 100644 --- a/pkg/ucp/integrationtests/radius/proxy_test.go +++ b/pkg/ucp/integrationtests/radius/proxy_test.go @@ -28,9 +28,8 @@ import ( backend_ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" - "github.com/radius-project/radius/pkg/ucp/frontend/api" "github.com/radius-project/radius/pkg/ucp/integrationtests/testrp" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -48,7 +47,7 @@ const ( ) func Test_RadiusPlane_Proxy_ResourceGroupDoesNotExist(t *testing.T) { - ucp := testserver.StartWithETCD(t, api.DefaultModules) + ucp := testhost.Start(t) rp := testrp.Start(t) rps := map[string]*string{ @@ -62,7 +61,7 @@ func Test_RadiusPlane_Proxy_ResourceGroupDoesNotExist(t *testing.T) { } func Test_RadiusPlane_ResourceSync(t *testing.T) { - ucp := testserver.StartWithETCD(t, api.DefaultModules) + ucp := testhost.Start(t) rp := testrp.Start(t) rp.Handler = testrp.SyncResource(t, ucp, testResourceGroupID) @@ -158,7 +157,7 @@ func Test_RadiusPlane_ResourceSync(t *testing.T) { } func Test_RadiusPlane_ResourceAsync(t *testing.T) { - ucp := testserver.StartWithETCD(t, api.DefaultModules) + ucp := testhost.Start(t) rp := testrp.Start(t) // Block background work item completion until we're ready. @@ -176,7 +175,7 @@ func Test_RadiusPlane_ResourceAsync(t *testing.T) { return result, nil } - client, err := ucp.Clients.DatabaseProvider.GetClient(ctx) + client, err := ucp.Options().DatabaseProvider.GetClient(ctx) require.NoError(t, err) err = client.Delete(ctx, testResourceID) require.NoError(t, err) @@ -222,8 +221,8 @@ func Test_RadiusPlane_ResourceAsync(t *testing.T) { location := response.Raw.Header.Get("Location") azureAsyncOperation := response.Raw.Header.Get("Azure-AsyncOperation") - require.True(t, strings.HasPrefix(location, ucp.BaseURL), "Location starts with UCP URL") - require.True(t, strings.HasPrefix(azureAsyncOperation, ucp.BaseURL), "Azure-AsyncOperation starts with UCP URL") + require.True(t, strings.HasPrefix(location, ucp.BaseURL()), "Location starts with UCP URL") + require.True(t, strings.HasPrefix(azureAsyncOperation, ucp.BaseURL()), "Azure-AsyncOperation starts with UCP URL") }) t.Run("LIST (during PUT)", func(t *testing.T) { @@ -382,7 +381,7 @@ func Test_RadiusPlane_ResourceAsync(t *testing.T) { }) } -func createRadiusPlane(ucp *testserver.TestServer, resourceProviders map[string]*string) { +func createRadiusPlane(ucp *testhost.TestHost, resourceProviders map[string]*string) { body := v20231001preview.RadiusPlaneResource{ Location: to.Ptr(v1.LocationGlobal), Properties: &v20231001preview.RadiusPlaneResourceProperties{ @@ -393,7 +392,7 @@ func createRadiusPlane(ucp *testserver.TestServer, resourceProviders map[string] response.EqualsStatusCode(http.StatusOK) } -func createResourceGroup(ucp *testserver.TestServer, id string) { +func createResourceGroup(ucp *testhost.TestHost, id string) { body := v20231001preview.ResourceGroupResource{ Location: to.Ptr(v1.LocationGlobal), Properties: &v20231001preview.ResourceGroupProperties{}, diff --git a/pkg/ucp/integrationtests/resourcegroups/resourcegroups_test.go b/pkg/ucp/integrationtests/resourcegroups/resourcegroups_test.go index 5106fcf314..a047c3bde2 100644 --- a/pkg/ucp/integrationtests/resourcegroups/resourcegroups_test.go +++ b/pkg/ucp/integrationtests/resourcegroups/resourcegroups_test.go @@ -20,8 +20,7 @@ import ( "testing" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" ) const ( @@ -40,13 +39,13 @@ const ( resourceGroupInvalidResponseFixture = "testdata/resourcegroup_invalid_v20231001preview_responsebody.json" ) -func createRadiusPlane(server *testserver.TestServer) { +func createRadiusPlane(server *testhost.TestHost) { response := server.MakeFixtureRequest("PUT", radiusPlaneResourceURL, radiusPlaneRequestFixture) response.EqualsFixture(200, radiusPlaneResponseFixture) } func Test_ResourceGroup_PUT_Create(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) @@ -56,7 +55,7 @@ func Test_ResourceGroup_PUT_Create(t *testing.T) { } func Test_ResourceGroup_PUT_Update(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) @@ -69,7 +68,7 @@ func Test_ResourceGroup_PUT_Update(t *testing.T) { } func Test_ResourceGroup_PUT_APIValidation(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) @@ -79,7 +78,7 @@ func Test_ResourceGroup_PUT_APIValidation(t *testing.T) { } func Test_ResourceGroup_GET_Empty(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) @@ -89,7 +88,7 @@ func Test_ResourceGroup_GET_Empty(t *testing.T) { } func Test_ResourceGroup_GET_Found(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) @@ -102,7 +101,7 @@ func Test_ResourceGroup_GET_Found(t *testing.T) { } func Test_ResourceGroup_LIST(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) diff --git a/pkg/ucp/integrationtests/resourceproviders/apiversions_test.go b/pkg/ucp/integrationtests/resourceproviders/apiversions_test.go index 8ba1d22fce..71619894fd 100644 --- a/pkg/ucp/integrationtests/resourceproviders/apiversions_test.go +++ b/pkg/ucp/integrationtests/resourceproviders/apiversions_test.go @@ -20,8 +20,7 @@ import ( "net/http" "testing" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/require" ) @@ -31,7 +30,7 @@ const ( ) func Test_APIVersion_Lifecycle(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) diff --git a/pkg/ucp/integrationtests/resourceproviders/locations_test.go b/pkg/ucp/integrationtests/resourceproviders/locations_test.go index 64923fbc9a..7fdd82045a 100644 --- a/pkg/ucp/integrationtests/resourceproviders/locations_test.go +++ b/pkg/ucp/integrationtests/resourceproviders/locations_test.go @@ -20,8 +20,7 @@ import ( "net/http" "testing" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/require" ) @@ -31,7 +30,7 @@ const ( ) func Test_Location_Lifecycle(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) diff --git a/pkg/ucp/integrationtests/resourceproviders/resourceproviders_test.go b/pkg/ucp/integrationtests/resourceproviders/resourceproviders_test.go index a67000cf8b..7ad93f4096 100644 --- a/pkg/ucp/integrationtests/resourceproviders/resourceproviders_test.go +++ b/pkg/ucp/integrationtests/resourceproviders/resourceproviders_test.go @@ -20,8 +20,7 @@ import ( "net/http" "testing" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/require" ) @@ -31,7 +30,7 @@ const ( ) func Test_ResourceProvider_Lifecycle(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) @@ -61,7 +60,7 @@ func Test_ResourceProvider_Lifecycle(t *testing.T) { } func Test_ResourceProvider_CascadingDelete(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) diff --git a/pkg/ucp/integrationtests/resourceproviders/resourcetypes_test.go b/pkg/ucp/integrationtests/resourceproviders/resourcetypes_test.go index b8ae588548..6db34e64ce 100644 --- a/pkg/ucp/integrationtests/resourceproviders/resourcetypes_test.go +++ b/pkg/ucp/integrationtests/resourceproviders/resourcetypes_test.go @@ -20,8 +20,7 @@ import ( "net/http" "testing" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/require" ) @@ -31,7 +30,7 @@ const ( ) func Test_ResourceType_Lifecycle(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) @@ -64,7 +63,7 @@ func Test_ResourceType_Lifecycle(t *testing.T) { } func Test_ResourceType_CascadingDelete(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) diff --git a/pkg/ucp/integrationtests/resourceproviders/summary_test.go b/pkg/ucp/integrationtests/resourceproviders/summary_test.go index 9fbfb19b35..096f3165ec 100644 --- a/pkg/ucp/integrationtests/resourceproviders/summary_test.go +++ b/pkg/ucp/integrationtests/resourceproviders/summary_test.go @@ -23,13 +23,12 @@ import ( "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/stretchr/testify/require" ) func Test_ResourceProviderSummary_Lifecycle(t *testing.T) { - server := testserver.StartWithETCD(t, api.DefaultModules) + server := testhost.Start(t) defer server.Close() createRadiusPlane(server) diff --git a/pkg/ucp/integrationtests/resourceproviders/util_test.go b/pkg/ucp/integrationtests/resourceproviders/util_test.go index efca2af228..3378a8281a 100644 --- a/pkg/ucp/integrationtests/resourceproviders/util_test.go +++ b/pkg/ucp/integrationtests/resourceproviders/util_test.go @@ -16,7 +16,7 @@ limitations under the License. package resourceproviders -import "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" +import "github.com/radius-project/radius/pkg/ucp/testhost" const ( radiusAPIVersion = "?api-version=2023-10-01-preview" @@ -56,7 +56,7 @@ const ( resourceProviderSummaryURL = "/planes/radius/local/providers/" + resourceProviderNamespace + radiusAPIVersion ) -func createRadiusPlane(server *testserver.TestServer) { +func createRadiusPlane(server *testhost.TestHost) { response := server.MakeFixtureRequest("PUT", radiusPlaneResourceURL, radiusPlaneRequestFixture) response.WaitForOperationComplete(nil) @@ -64,7 +64,7 @@ func createRadiusPlane(server *testserver.TestServer) { response.EqualsFixture(200, radiusPlaneResponseFixture) } -func createResourceProvider(server *testserver.TestServer) { +func createResourceProvider(server *testhost.TestHost) { response := server.MakeFixtureRequest("PUT", resourceProviderURL, resourceProviderRequestFixture) response.WaitForOperationComplete(nil) @@ -72,7 +72,7 @@ func createResourceProvider(server *testserver.TestServer) { response.EqualsFixture(200, resourceProviderResponseFixture) } -func deleteResourceProvider(server *testserver.TestServer) { +func deleteResourceProvider(server *testhost.TestHost) { response := server.MakeRequest("DELETE", resourceProviderURL, nil) response.WaitForOperationComplete(nil) @@ -80,7 +80,7 @@ func deleteResourceProvider(server *testserver.TestServer) { response.EqualsStatusCode(404) } -func createResourceType(server *testserver.TestServer) { +func createResourceType(server *testhost.TestHost) { response := server.MakeFixtureRequest("PUT", resourceTypeURL, resourceTypeRequestFixture) response.WaitForOperationComplete(nil) @@ -88,7 +88,7 @@ func createResourceType(server *testserver.TestServer) { response.EqualsFixture(200, resourceTypeResponseFixture) } -func deleteResourceType(server *testserver.TestServer) { +func deleteResourceType(server *testhost.TestHost) { response := server.MakeRequest("DELETE", resourceTypeURL, nil) response.WaitForOperationComplete(nil) @@ -96,7 +96,7 @@ func deleteResourceType(server *testserver.TestServer) { response.EqualsStatusCode(404) } -func createAPIVersion(server *testserver.TestServer) { +func createAPIVersion(server *testhost.TestHost) { response := server.MakeFixtureRequest("PUT", apiVersionURL, apiVersionRequestFixture) response.WaitForOperationComplete(nil) @@ -104,7 +104,7 @@ func createAPIVersion(server *testserver.TestServer) { response.EqualsFixture(200, apiVersionResponseFixture) } -func deleteAPIVersion(server *testserver.TestServer) { +func deleteAPIVersion(server *testhost.TestHost) { response := server.MakeRequest("DELETE", apiVersionURL, nil) response.WaitForOperationComplete(nil) @@ -112,7 +112,7 @@ func deleteAPIVersion(server *testserver.TestServer) { response.EqualsStatusCode(404) } -func createLocation(server *testserver.TestServer) { +func createLocation(server *testhost.TestHost) { response := server.MakeFixtureRequest("PUT", locationURL, locationRequestFixture) response.WaitForOperationComplete(nil) @@ -120,7 +120,7 @@ func createLocation(server *testserver.TestServer) { response.EqualsFixture(200, locationResponseFixture) } -func deleteLocation(server *testserver.TestServer) { +func deleteLocation(server *testhost.TestHost) { response := server.MakeRequest("DELETE", locationURL, nil) response.WaitForOperationComplete(nil) diff --git a/pkg/ucp/integrationtests/testrp/async.go b/pkg/ucp/integrationtests/testrp/async.go index 1fbcac1098..ce8cb52596 100644 --- a/pkg/ucp/integrationtests/testrp/async.go +++ b/pkg/ucp/integrationtests/testrp/async.go @@ -33,7 +33,7 @@ import ( "github.com/radius-project/radius/pkg/armrpc/servicecontext" "github.com/radius-project/radius/pkg/components/queue/queueprovider" "github.com/radius-project/radius/pkg/middleware" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/radius-project/radius/test/testcontext" "github.com/stretchr/testify/require" ) @@ -50,7 +50,7 @@ func (b *BackendFuncController) Run(ctx context.Context, request *backend_ctrl.R } // AsyncResource creates an HTTP handler that can be used to test asynchronous resource lifecycle operations. -func AsyncResource(t *testing.T, ts *testserver.TestServer, rootScope string, put BackendFunc, delete BackendFunc) func(w http.ResponseWriter, r *http.Request) { +func AsyncResource(t *testing.T, ts *testhost.TestHost, rootScope string, put BackendFunc, delete BackendFunc) func(w http.ResponseWriter, r *http.Request) { rootScope = strings.ToLower(rootScope) ctx := testcontext.New(t) @@ -60,7 +60,7 @@ func AsyncResource(t *testing.T, ts *testserver.TestServer, rootScope string, pu resourceType := "System.Test/testResources" // We can share the database provider with the test server. - databaseClient, err := ts.Clients.DatabaseProvider.GetClient(ctx) + databaseClient, err := ts.Options().DatabaseProvider.GetClient(ctx) require.NoError(t, err) // Do not share the queue. diff --git a/pkg/ucp/integrationtests/testrp/sync.go b/pkg/ucp/integrationtests/testrp/sync.go index 8adcf7e1cd..af69509eef 100644 --- a/pkg/ucp/integrationtests/testrp/sync.go +++ b/pkg/ucp/integrationtests/testrp/sync.go @@ -30,13 +30,13 @@ import ( "github.com/radius-project/radius/pkg/armrpc/servicecontext" "github.com/radius-project/radius/pkg/components/queue/queueprovider" "github.com/radius-project/radius/pkg/middleware" - "github.com/radius-project/radius/pkg/ucp/integrationtests/testserver" + "github.com/radius-project/radius/pkg/ucp/testhost" "github.com/radius-project/radius/test/testcontext" "github.com/stretchr/testify/require" ) // SyncResource creates an HTTP handler that can be used to test synchronous resource lifecycle operations. -func SyncResource(t *testing.T, ts *testserver.TestServer, rootScope string) func(w http.ResponseWriter, r *http.Request) { +func SyncResource(t *testing.T, ts *testhost.TestHost, rootScope string) func(w http.ResponseWriter, r *http.Request) { rootScope = strings.ToLower(rootScope) ctx := testcontext.New(t) @@ -44,7 +44,7 @@ func SyncResource(t *testing.T, ts *testserver.TestServer, rootScope string) fun r.Use(servicecontext.ARMRequestCtx("", v1.LocationGlobal), middleware.LowercaseURLPath) // We can share the database provider with the test server. - databaseClient, err := ts.Clients.DatabaseProvider.GetClient(ctx) + databaseClient, err := ts.Options().DatabaseProvider.GetClient(ctx) require.NoError(t, err) // Do not share the queue. diff --git a/pkg/ucp/integrationtests/testserver/testserver.go b/pkg/ucp/integrationtests/testserver/testserver.go deleted file mode 100644 index 9c59873171..0000000000 --- a/pkg/ucp/integrationtests/testserver/testserver.go +++ /dev/null @@ -1,604 +0,0 @@ -/* -Copyright 2023 The Radius Authors. - -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 testserver - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "net/url" - "os" - "sync" - "testing" - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-logr/logr" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - etcdclient "go.etcd.io/etcd/client/v3" - "go.uber.org/mock/gomock" - - v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" - backend_ctrl "github.com/radius-project/radius/pkg/armrpc/asyncoperation/controller" - "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" - "github.com/radius-project/radius/pkg/armrpc/asyncoperation/worker" - "github.com/radius-project/radius/pkg/armrpc/rpctest" - "github.com/radius-project/radius/pkg/armrpc/servicecontext" - "github.com/radius-project/radius/pkg/components/database" - "github.com/radius-project/radius/pkg/components/database/databaseprovider" - "github.com/radius-project/radius/pkg/components/queue" - "github.com/radius-project/radius/pkg/components/queue/queueprovider" - "github.com/radius-project/radius/pkg/components/secret" - "github.com/radius-project/radius/pkg/components/secret/secretprovider" - "github.com/radius-project/radius/pkg/middleware" - "github.com/radius-project/radius/pkg/sdk" - "github.com/radius-project/radius/pkg/ucp/backend" - "github.com/radius-project/radius/pkg/ucp/data" - "github.com/radius-project/radius/pkg/ucp/frontend/api" - "github.com/radius-project/radius/pkg/ucp/frontend/modules" - "github.com/radius-project/radius/pkg/ucp/hosting" - "github.com/radius-project/radius/pkg/ucp/hostoptions" - "github.com/radius-project/radius/pkg/ucp/server" - "github.com/radius-project/radius/pkg/validator" - "github.com/radius-project/radius/swagger" - "github.com/radius-project/radius/test/testcontext" -) - -// NoModules can be used to start a test server without any modules. This is useful for testing the server itself and core functionality -// like planes. -func NoModules(options modules.Options) []modules.Initializer { - return nil -} - -// TestServer can run a UCP server using the Go httptest package. It provides access to an isolated ETCD instances for storage -// of resources and secrets. Alteratively, it can also be used with gomock. -// -// Do not create a TestServer directly, use StartWithETCD or StartWithMocks instead. -type TestServer struct { - // BaseURL is the base URL of the server, including the path base. - BaseURL string - - // Clients gets access to the clients created by TestServer regardless of whether - // they are mocks. - Clients *TestServerClients - - // Mocks gets access to the mock clients. Will be nil if StartWithETCD is used. - Mocks *TestServerMocks - - // Server provides access to the test HTTP server. - Server *httptest.Server - - cancel context.CancelFunc - etcdService *data.EmbeddedETCDService - etcdClient *etcdclient.Client - t *testing.T - stoppedChan <-chan struct{} - shutdown sync.Once -} - -// TestServerClients provides access to the clients created by the TestServer. -type TestServerClients struct { - // QueueProvider is the queue client provider. - QueueProvider *queueprovider.QueueProvider - - // SecretProvider is the secret client provider. - SecretProvider *secretprovider.SecretProvider - - // DatabaseProvider is the database client provider. - DatabaseProvider *databaseprovider.DatabaseProvider -} - -// TestServerMocks provides access to mock instances created by the TestServer. -type TestServerMocks struct { - // Secrets is the mock secret client. - Secrets *secret.MockClient - - // Database is the mock database client. - Database *database.MockClient -} - -// Client provides access to an http.Client that can be used to send requests. Most tests should use the functionality -// like MakeRequest instead of testing the client directly. -func (ts *TestServer) Client() *http.Client { - return ts.Server.Client() -} - -// Close shuts down the server and will block until shutdown completes. -func (ts *TestServer) Close() { - // We're being picking about resource cleanup here, because unless we are picky we hit scalability - // problems in tests pretty quickly. - ts.shutdown.Do(func() { - ts.cancel() // Start ETCD shutdown - ts.Server.Close() // Stop HTTP server - - if ts.etcdClient != nil { - ts.etcdClient.Close() // Stop ETCD Client - } - - if ts.stoppedChan != nil { - <-ts.stoppedChan // ETCD stopped - } - }) -} - -// StartWithMocks creates and starts a new TestServer that used an mocks for storage. -func StartWithMocks(t *testing.T, configureModules func(options modules.Options) []modules.Initializer) *TestServer { - ctx, cancel := testcontext.NewWithCancel(t) - - // Generate a random base path to ensure we're handling it correctly. - pathBase := "/" + uuid.New().String() - - ctrl := gomock.NewController(t) - databaseClient := database.NewMockClient(ctrl) - databaseProvider := databaseprovider.FromClient(databaseClient) - - queueClient := queue.NewMockClient(ctrl) - queueProvider := queueprovider.New(queueprovider.QueueProviderOptions{Name: "System.Resources"}) - queueProvider.SetClient(queueClient) - - secretClient := secret.NewMockClient(ctrl) - secretProvider := secretprovider.NewSecretProvider(secretprovider.SecretProviderOptions{}) - secretProvider.SetClient(secretClient) - - statusManager := statusmanager.NewMockStatusManager(ctrl) - - router := chi.NewRouter() - router.Use(servicecontext.ARMRequestCtx(pathBase, "global")) - - app := http.Handler(router) - app = middleware.NormalizePath(app) - server := httptest.NewUnstartedServer(app) - server.Config.BaseContext = func(l net.Listener) context.Context { - return ctx - } - - specLoader, err := validator.LoadSpec(ctx, "ucp", swagger.SpecFilesUCP, []string{pathBase}, "") - require.NoError(t, err, "failed to load OpenAPI spec") - - options := modules.Options{ - Address: "localhost:9999", // Will be dynamically populated when server is started - PathBase: pathBase, - Config: &hostoptions.UCPConfig{}, - DatabaseProvider: databaseProvider, - SecretProvider: secretProvider, - SpecLoader: specLoader, - StatusManager: statusManager, - } - - if configureModules == nil { - configureModules = api.DefaultModules - } - - modules := configureModules(options) - - err = api.Register(ctx, router, modules, options) - require.NoError(t, err) - - logger := logr.FromContextOrDiscard(ctx) - logger.Info("Starting HTTP server...") - server.Start() - logger.Info(fmt.Sprintf("Started HTTP server on %s...", server.URL)) - - ucp := &TestServer{ - BaseURL: server.URL + pathBase, - Clients: &TestServerClients{ - QueueProvider: queueProvider, - SecretProvider: secretProvider, - DatabaseProvider: databaseProvider, - }, - Mocks: &TestServerMocks{ - Secrets: secretClient, - Database: databaseClient, - }, - Server: server, - cancel: cancel, - t: t, - } - - t.Cleanup(ucp.Close) - return ucp -} - -// StartWithETCD creates and starts a new TestServer that used an embedded ETCD instance for storage. -func StartWithETCD(t *testing.T, configureModules func(options modules.Options) []modules.Initializer) *TestServer { - config := hosting.NewAsyncValue[etcdclient.Client]() - etcd := data.NewEmbeddedETCDService(data.EmbeddedETCDServiceOptions{ - ClientConfigSink: config, - AssignRandomPorts: true, - Quiet: false, - }) - - ctx, cancel := testcontext.NewWithCancel(t) - t.Cleanup(cancel) - - stoppedChan := make(chan struct{}) - defer close(stoppedChan) - go func() { - // We can't pass the test logger into the etcd service because it is forbidden to log - // using the test logger after the test finishes. - // - // https://github.com/golang/go/issues/40343 - // - // If you need to see the logging output while you are testing, then comment out the next line - // and you'll be able to see the spam from etcd. - // - // This is caught by the race checker and will fail your pr if you do it. - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - err := etcd.Run(ctx) - if err != nil { - t.Logf("error from etcd: %v", err) - } - }() - - databaseOptions := databaseprovider.Options{ - Provider: databaseprovider.TypeETCD, - ETCD: databaseprovider.ETCDOptions{ - InMemory: true, - Client: config, - }, - } - secretOptions := secretprovider.SecretProviderOptions{ - Provider: secretprovider.TypeETCDSecret, - ETCD: databaseOptions.ETCD, - } - queueOptions := queueprovider.QueueProviderOptions{ - Name: server.UCPProviderName, - Provider: queueprovider.TypeInmemory, - InMemory: &queueprovider.InMemoryQueueOptions{}, - } - - // Generate a random base path to ensure we're handling it correctly. - pathBase := "/" + uuid.New().String() - databaseProvider := databaseprovider.FromOptions(databaseOptions) - secretProvider := secretprovider.NewSecretProvider(secretOptions) - queueProvider := queueprovider.New(queueOptions) - - queueClient, err := queueProvider.GetClient(ctx) - require.NoError(t, err) - - router := chi.NewRouter() - router.Use(servicecontext.ARMRequestCtx(pathBase, "global")) - - app := middleware.NormalizePath(router) - server := httptest.NewUnstartedServer(app) - server.Config.BaseContext = func(l net.Listener) context.Context { - return ctx - } - - // Unfortunately the server doesn't populate 'server.URL' until it is started, so we have to build it ourselves. - address := "http://" + server.Listener.Addr().String() - connection, err := sdk.NewDirectConnection(address + pathBase) - require.NoError(t, err) - - databaseClient, err := databaseProvider.GetClient(ctx) - require.NoError(t, err) - - statusManager := statusmanager.New(databaseClient, queueClient, v1.LocationGlobal) - - specLoader, err := validator.LoadSpec(ctx, "ucp", swagger.SpecFilesUCP, []string{pathBase}, "") - require.NoError(t, err, "failed to load OpenAPI spec") - - options := modules.Options{ - Address: address, - PathBase: pathBase, - Config: &hostoptions.UCPConfig{}, - DatabaseProvider: databaseProvider, - SecretProvider: secretProvider, - SpecLoader: specLoader, - QueueProvider: queueProvider, - StatusManager: statusManager, - } - - if configureModules == nil { - configureModules = api.DefaultModules - } - - modules := configureModules(options) - - // The URL for the dynamic-rp needs to be specified in configuration, however not all of our tests - // need to use the dynamic-rp. We can just use a placeholder value here. - if options.Config.Routing.DefaultDownstreamEndpoint == "" { - options.Config.Routing.DefaultDownstreamEndpoint = "http://localhost:65535" - } - - defaultDownstream, err := url.Parse(options.Config.Routing.DefaultDownstreamEndpoint) - require.NoError(t, err) - - registry := worker.NewControllerRegistry() - err = backend.RegisterControllers(registry, connection, http.DefaultTransport, backend_ctrl.Options{DatabaseClient: databaseClient}, defaultDownstream) - require.NoError(t, err) - - w := worker.New(worker.Options{}, statusManager, queueClient, registry) - go func() { - err = w.Start(ctx) - require.NoError(t, err) - }() - - err = api.Register(ctx, router, modules, options) - require.NoError(t, err) - - logger := logr.FromContextOrDiscard(ctx) - logger.Info("Starting HTTP server...") - server.Start() - logger.Info(fmt.Sprintf("Started HTTP server on %s...", server.URL)) - - logger.Info("Connecting to data store...") - client, err := config.Get(ctx) - require.NoError(t, err, "failed to access etcd client") - _, err = client.Cluster.MemberList(ctx) - require.NoError(t, err, "failed to query etcd") - logger.Info("Connected to data store") - - // TODO: start worker - - ucp := &TestServer{ - BaseURL: server.URL + pathBase, - Clients: &TestServerClients{ - QueueProvider: queueProvider, - SecretProvider: secretProvider, - DatabaseProvider: databaseProvider, - }, - Server: server, - cancel: cancel, - etcdService: etcd, - etcdClient: client, - t: t, - stoppedChan: stoppedChan, - } - t.Cleanup(ucp.Close) - return ucp -} - -// TestResponse is return from requests made against a TestServer. Tests should use the functions defined -// on TestResponse for valiation. -type TestResponse struct { - Raw *http.Response - Body *bytes.Buffer - Error *v1.ErrorResponse - t *testing.T - server *TestServer -} - -// MakeFixtureRequest sends a request to the server using a file on disk as the payload (body). Use the fixture -// parameter to specify the path to a file. -func (ts *TestServer) MakeFixtureRequest(method string, pathAndQuery string, fixture string) *TestResponse { - body, err := os.ReadFile(fixture) - require.NoError(ts.t, err, "reading fixture failed") - return ts.MakeRequest(method, pathAndQuery, body) -} - -// MakeTypedRequest sends a request to the server by marshalling the provided object to JSON. -func (ts *TestServer) MakeTypedRequest(method string, pathAndQuery string, body any) *TestResponse { - if body == nil { - return ts.MakeRequest(method, pathAndQuery, nil) - } - - b, err := json.Marshal(body) - require.NoError(ts.t, err, "marshalling body failed") - return ts.MakeRequest(method, pathAndQuery, b) -} - -// MakeRequest sends a request to the server. -func (ts *TestServer) MakeRequest(method string, pathAndQuery string, body []byte) *TestResponse { - // Prepend the base path if this is a relative URL. - requestUrl := pathAndQuery - parsed, err := url.Parse(pathAndQuery) - require.NoError(ts.t, err, "parsing URL failed") - if !parsed.IsAbs() { - requestUrl = ts.BaseURL + pathAndQuery - } - - client := ts.Server.Client() - request, err := rpctest.NewHTTPRequestWithContent(context.Background(), method, requestUrl, body) - require.NoError(ts.t, err, "creating request failed") - - ctx := rpctest.NewARMRequestContext(request) - request = request.WithContext(ctx) - - response, err := client.Do(request) - require.NoError(ts.t, err, "sending request failed") - - // Buffer the response so we can read multiple times. - responseBuffer := &bytes.Buffer{} - _, err = io.Copy(responseBuffer, response.Body) - response.Body.Close() - require.NoError(ts.t, err, "copying response failed") - - response.Body = io.NopCloser(responseBuffer) - - // Pretty-print response for logs. - if len(responseBuffer.Bytes()) > 0 { - var data any - err = json.Unmarshal(responseBuffer.Bytes(), &data) - require.NoError(ts.t, err, "unmarshalling response failed") - - text, err := json.MarshalIndent(&data, "", " ") - require.NoError(ts.t, err, "marshalling response failed") - ts.t.Log("Response Body: \n" + string(text)) - } - - var errorResponse *v1.ErrorResponse - if response.StatusCode >= 400 { - // The response MUST be an arm error for a non-success status code. - errorResponse = &v1.ErrorResponse{} - err := json.Unmarshal(responseBuffer.Bytes(), &errorResponse) - require.NoError(ts.t, err, "unmarshalling error response failed - THIS IS A SERIOUS BUG. ALL ERROR RESPONSES MUST USE THE STANDARD FORMAT") - } - - return &TestResponse{Raw: response, Body: responseBuffer, Error: errorResponse, server: ts, t: ts.t} -} - -// EqualsErrorCode compares a TestResponse against an expected status code and error code. EqualsErrorCode assumes the response -// uses the ARM error format (required for our APIs). -func (tr *TestResponse) EqualsErrorCode(statusCode int, code string) { - require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") - require.NotNil(tr.t, tr.Error, "expected an error but actual response did not contain one") - require.Equal(tr.t, code, tr.Error.Error.Code, "actual error code was different from expected") -} - -// EqualsFixture compares a TestResponse against an expected status code and body payload. Use the fixture parameter to specify -// the path to a file. -func (tr *TestResponse) EqualsFixture(statusCode int, fixture string) { - body, err := os.ReadFile(fixture) - require.NoError(tr.t, err, "reading fixture failed") - tr.EqualsResponse(statusCode, body) -} - -// EqualsStatusCode compares a TestResponse against an expected status code (ingnores the body payload). -func (tr *TestResponse) EqualsStatusCode(statusCode int) { - require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") -} - -// EqualsFixture compares a TestResponse against an expected status code and body payload. -func (tr *TestResponse) EqualsResponse(statusCode int, body []byte) { - if len(body) == 0 { - require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") - require.Empty(tr.t, tr.Body.Bytes(), "expected an empty response but actual response had a body") - return - } - - var expected map[string]any - err := json.Unmarshal(body, &expected) - require.NoError(tr.t, err, "unmarshalling expected response failed") - - var actual map[string]any - err = json.Unmarshal(tr.Body.Bytes(), &actual) - - tr.removeSystemData(actual) - - require.NoError(tr.t, err, "unmarshalling actual response failed") - require.EqualValues(tr.t, expected, actual, "response body did not match expected") - require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") -} - -// EqualsValue compares a TestResponse against an expected status code and an response body. -// -// If the systemData propert is present in the response, it will be removed. -func (tr *TestResponse) EqualsValue(statusCode int, expected any) { - var actual map[string]any - err := json.Unmarshal(tr.Body.Bytes(), &actual) - require.NoError(tr.t, err, "unmarshalling actual response failed") - - // Convert expected input to map[string]any to compare with actual response. - expectedBytes, err := json.Marshal(expected) - require.NoError(tr.t, err, "marshalling expected response failed") - - var expectedMap map[string]any - err = json.Unmarshal(expectedBytes, &expectedMap) - require.NoError(tr.t, err, "unmarshalling expected response failed") - - tr.removeSystemData(expectedMap) - tr.removeSystemData(actual) - - require.EqualValues(tr.t, expectedMap, actual, "response body did not match expected") - require.Equal(tr.t, statusCode, tr.Raw.StatusCode, "status code did not match expected") -} - -// EqualsEmptyList compares a TestResponse against an expected status code and an empty resource list. -func (tr *TestResponse) EqualsEmptyList() { - expected := map[string]any{ - "value": []any{}, - } - - var actual map[string]any - err := json.Unmarshal(tr.Body.Bytes(), &actual) - - tr.removeSystemData(actual) - - require.NoError(tr.t, err, "unmarshalling actual response failed") - require.EqualValues(tr.t, expected, actual, "response body did not match expected") - require.Equal(tr.t, http.StatusOK, tr.Raw.StatusCode, "status code did not match expected") -} - -func (tr *TestResponse) ReadAs(obj any) { - tr.t.Helper() - - decoder := json.NewDecoder(tr.Body) - decoder.DisallowUnknownFields() - - err := decoder.Decode(obj) - require.NoError(tr.t, err, "unmarshalling expected response failed") -} - -func (tr *TestResponse) WaitForOperationComplete(timeout *time.Duration) *TestResponse { - if tr.Raw.StatusCode != http.StatusCreated && tr.Raw.StatusCode != http.StatusAccepted { - // Response is already terminal. - return tr - } - - if timeout == nil { - x := 30 * time.Second - timeout = &x - } - - timer := time.After(*timeout) - poller := time.NewTicker(1 * time.Second) - defer poller.Stop() - for { - select { - case <-timer: - tr.t.Fatalf("timed out waiting for operation to complete") - return nil // unreachable - case <-poller.C: - // The Location header should give us the operation status URL. - response := tr.server.MakeRequest(http.MethodGet, tr.Raw.Header.Get("Azure-AsyncOperation"), nil) - // To determine if the response is terminal we need to read the provisioning state field. - - operationStatus := v1.AsyncOperationStatus{} - response.ReadAs(&operationStatus) - if operationStatus.Status.IsTerminal() { - // Response is terminal. - return response - } - - continue - } - } -} - -func (tr *TestResponse) removeSystemData(responseBody map[string]any) { - // Delete systemData property if found, it's not stable so we don't include it in baselines. - _, ok := responseBody["systemData"] - if ok { - delete(responseBody, "systemData") - return - } - - value, ok := responseBody["value"] - if !ok { - return - } - - valueSlice, ok := value.([]any) - if !ok { - return - } - - for _, v := range valueSlice { - if vMap, ok := v.(map[string]any); ok { - tr.removeSystemData(vMap) - } - } -} diff --git a/pkg/ucp/options.go b/pkg/ucp/options.go new file mode 100644 index 0000000000..0e151f1e9d --- /dev/null +++ b/pkg/ucp/options.go @@ -0,0 +1,116 @@ +/* +Copyright 2023 The Radius Authors. + +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 ucp + +import ( + "context" + "fmt" + + "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" + "github.com/radius-project/radius/pkg/components/database/databaseprovider" + "github.com/radius-project/radius/pkg/components/queue/queueprovider" + "github.com/radius-project/radius/pkg/components/secret/secretprovider" + "github.com/radius-project/radius/pkg/kubeutil" + "github.com/radius-project/radius/pkg/sdk" + ucpconfig "github.com/radius-project/radius/pkg/ucp/config" + "github.com/radius-project/radius/pkg/ucp/frontend/modules" + "github.com/radius-project/radius/pkg/validator" + "github.com/radius-project/radius/swagger" + kube_rest "k8s.io/client-go/rest" +) + +// Options holds the configuration options and shared services for the UCP server. +// +// For testability, all fields on this struct MUST be constructed from the NewOptions function without any +// additional initialization required. +type Options struct { + // Config is the configuration for the server. + Config *Config + + // DatabaseProvider provides access to the database used for resource data. + DatabaseProvider *databaseprovider.DatabaseProvider + + // Modules is the list of modules to initialize. This will default to nil (implying the default set), and + // can be overridden by tests. + Modules []modules.Initializer + + // QueueProvider provides access to the message queue client. + QueueProvider *queueprovider.QueueProvider + + // SecretProvider provides access to secret store used for secret data. + SecretProvider *secretprovider.SecretProvider + + // SpecLoader is the loader for the OpenAPI spec. + SpecLoader *validator.Loader + + // StatusManager implements operations on async operation statuses. + StatusManager statusmanager.StatusManager + + // UCP is the connection to UCP + UCP sdk.Connection +} + +// NewOptions creates a new Options instance from the given configuration. +func NewOptions(ctx context.Context, config *Config) (*Options, error) { + var err error + options := Options{ + Config: config, + + Modules: nil, // Default to nil, which implies the default set of modules. + } + + options.DatabaseProvider = databaseprovider.FromOptions(config.Database) + options.QueueProvider = queueprovider.New(config.Queue) + options.SecretProvider = secretprovider.NewSecretProvider(config.Secrets) + + databaseClient, err := options.DatabaseProvider.GetClient(ctx) + if err != nil { + return nil, err + } + + queueClient, err := options.QueueProvider.GetClient(ctx) + if err != nil { + return nil, err + } + + options.StatusManager = statusmanager.New(databaseClient, queueClient, config.Environment.RoleLocation) + + options.SpecLoader, err = validator.LoadSpec(ctx, "ucp", swagger.SpecFilesUCP, []string{config.Server.PathBase}, "") + if err != nil { + return nil, err + } + + var cfg *kube_rest.Config + if config.UCP.Kind == ucpconfig.UCPConnectionKindKubernetes { + cfg, err = kubeutil.NewClientConfig(&kubeutil.ConfigOptions{ + // TODO: Allow to use custom context via configuration. - https://github.com/radius-project/radius/issues/5433 + ContextName: "", + QPS: kubeutil.DefaultServerQPS, + Burst: kubeutil.DefaultServerBurst, + }) + if err != nil { + return nil, fmt.Errorf("failed to get kubernetes config: %w", err) + } + } + + options.UCP, err = ucpconfig.NewConnectionFromUCPConfig(&config.UCP, cfg) + if err != nil { + return nil, err + } + + return &options, nil +} diff --git a/pkg/ucp/rest/objects.go b/pkg/ucp/rest/objects.go deleted file mode 100644 index 0558fc7c58..0000000000 --- a/pkg/ucp/rest/objects.go +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2023 The Radius Authors. - -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 rest - -import "strings" - -type PlaneProperties struct { - ResourceProviders map[string]string `json:"resourceProviders" yaml:"resourceProviders"` // Used only for UCP native planes - Kind string `json:"kind" yaml:"kind"` - URL string `json:"url" yaml:"url"` // Used only for non UCP native planes and non AWS planes -} - -// Plane kinds -const ( - PlaneKindUCPNative = "UCPNative" - PlaneKindAzure = "Azure" - PlaneKindAWS = "AWS" -) - -type Plane struct { - ID string `json:"id" yaml:"id"` - Type string `json:"type" yaml:"type"` - Name string `json:"name" yaml:"name"` - Properties PlaneProperties `json:"properties" yaml:"properties"` -} - -// PlaneList represents a list of UCP planes in the ARM wire-format -type PlaneList struct { - Value []Plane `json:"value" yaml:"value"` -} - -// Resource represents a resource within a UCP resource group -type Resource struct { - ID string `json:"id" yaml:"id"` - Name string `json:"name" yaml:"name"` - ProvisioningState string `json:"provisioningState" yaml:"provisioningState"` - Type string `json:"type" yaml:"type"` -} - -// ResourceList represents a list of resources -type ResourceList struct { - Value []Resource `json:"value" yaml:"value"` -} - -// LookupResourceProvider searches through the ResourceProviders configured in UCP. -func (plane *Plane) LookupResourceProvider(key string) string { - var value string - for k, v := range plane.Properties.ResourceProviders { - if strings.EqualFold(k, key) { - value = v - break - } - } - return value -} diff --git a/pkg/ucp/server/server.go b/pkg/ucp/server/server.go index 375497a1cb..a656330a4a 100644 --- a/pkg/ucp/server/server.go +++ b/pkg/ucp/server/server.go @@ -17,192 +17,45 @@ limitations under the License. package server import ( - "errors" - "fmt" - "os" - "strings" - "time" - - hostopts "github.com/radius-project/radius/pkg/armrpc/hostoptions" "github.com/radius-project/radius/pkg/components/database/databaseprovider" - "github.com/radius-project/radius/pkg/components/queue/queueprovider" - "github.com/radius-project/radius/pkg/components/secret/secretprovider" - "github.com/radius-project/radius/pkg/kubeutil" - metricsprovider "github.com/radius-project/radius/pkg/metrics/provider" metricsservice "github.com/radius-project/radius/pkg/metrics/service" - profilerprovider "github.com/radius-project/radius/pkg/profiler/provider" profilerservice "github.com/radius-project/radius/pkg/profiler/service" - "github.com/radius-project/radius/pkg/sdk" "github.com/radius-project/radius/pkg/trace" + "github.com/radius-project/radius/pkg/ucp" "github.com/radius-project/radius/pkg/ucp/backend" - "github.com/radius-project/radius/pkg/ucp/config" "github.com/radius-project/radius/pkg/ucp/data" "github.com/radius-project/radius/pkg/ucp/frontend/api" "github.com/radius-project/radius/pkg/ucp/hosting" - "github.com/radius-project/radius/pkg/ucp/hostoptions" - "github.com/radius-project/radius/pkg/ucp/rest" - "github.com/radius-project/radius/pkg/ucp/ucplog" - - kube_rest "k8s.io/client-go/rest" -) - -const ( - HTTPServerStopTimeout = time.Second * 10 - ServiceName = "ucp" ) -type Options struct { - Config *hostoptions.UCPConfig - Port string - DatabaseProviderOptions databaseprovider.Options - LoggingOptions ucplog.LoggingOptions - SecretProviderOptions secretprovider.SecretProviderOptions - QueueProviderOptions queueprovider.QueueProviderOptions - MetricsProviderOptions metricsprovider.MetricsProviderOptions - ProfilerProviderOptions profilerprovider.ProfilerProviderOptions - TracerProviderOptions trace.Options - TLSCertDir string - PathBase string - InitialPlanes []rest.Plane - Identity hostoptions.Identity - UCPConnection sdk.Connection - Location string -} - -const UCPProviderName = "System.Resources" - -// NewServerOptionsFromEnvironment creates a new Options struct from environment variables and returns it along with any errors. -func NewServerOptionsFromEnvironment(configFilePath string) (Options, error) { - basePath, ok := os.LookupEnv("BASE_PATH") - if ok && len(basePath) > 0 && (!strings.HasPrefix(basePath, "/") || strings.HasSuffix(basePath, "/")) { - return Options{}, errors.New("env: BASE_PATH must begin with '/' and must not end with '/'") - } - - tlsCertDir := os.Getenv("TLS_CERT_DIR") - port := os.Getenv("PORT") - if port == "" { - return Options{}, errors.New("UCP Port number must be set") - } - - opts, err := hostoptions.NewHostOptionsFromEnvironment(configFilePath) - if err != nil { - return Options{}, err - } - - storeOpts := opts.Config.DatabaseProvider - planes := opts.Config.Planes - secretOpts := opts.Config.SecretProvider - qproviderOpts := opts.Config.QueueProvider - metricsOpts := opts.Config.MetricsProvider - traceOpts := opts.Config.TracerProvider - profilerOpts := opts.Config.ProfilerProvider - loggingOpts := opts.Config.Logging - identity := opts.Config.Identity - // Set the default authentication method if AuthMethod is not set. - if identity.AuthMethod == "" { - identity.AuthMethod = hostoptions.AuthDefault - } - - location := opts.Config.Location - if location == "" { - location = "global" - } - - var cfg *kube_rest.Config - if opts.Config.UCP.Kind == config.UCPConnectionKindKubernetes { - cfg, err = kubeutil.NewClientConfig(&kubeutil.ConfigOptions{ - // TODO: Allow to use custom context via configuration. - https://github.com/radius-project/radius/issues/5433 - ContextName: "", - QPS: kubeutil.DefaultServerQPS, - Burst: kubeutil.DefaultServerBurst, - }) - if err != nil { - return Options{}, fmt.Errorf("failed to get kubernetes config: %w", err) - } - } - - ucpConn, err := config.NewConnectionFromUCPConfig(&opts.Config.UCP, cfg) - if err != nil { - return Options{}, err - } - - return Options{ - Config: opts.Config, - Port: port, - TLSCertDir: tlsCertDir, - PathBase: basePath, - DatabaseProviderOptions: storeOpts, - SecretProviderOptions: secretOpts, - QueueProviderOptions: qproviderOpts, - MetricsProviderOptions: metricsOpts, - TracerProviderOptions: traceOpts, - ProfilerProviderOptions: profilerOpts, - LoggingOptions: loggingOpts, - InitialPlanes: planes, - Identity: identity, - UCPConnection: ucpConn, - Location: location, - }, nil -} - // NewServer creates a new hosting.Host instance with services for API, EmbeddedETCD, Metrics, Profiler and Backend (if // enabled) based on the given Options. -func NewServer(options *Options) (*hosting.Host, error) { +func NewServer(options *ucp.Options) (*hosting.Host, error) { hostingServices := []hosting.Service{ - api.NewService(api.ServiceOptions{ - ProviderName: UCPProviderName, - Address: ":" + options.Port, - PathBase: options.PathBase, - Config: options.Config, - Location: options.Location, - TLSCertDir: options.TLSCertDir, - DatabaseProviderOptions: options.DatabaseProviderOptions, - SecretProviderOptions: options.SecretProviderOptions, - QueueProviderOptions: options.QueueProviderOptions, - InitialPlanes: options.InitialPlanes, - Identity: options.Identity, - UCPConnection: options.UCPConnection, - }), + api.NewService(options), + backend.NewService(options), } - if options.DatabaseProviderOptions.Provider == databaseprovider.TypeETCD && - options.DatabaseProviderOptions.ETCD.InMemory { - hostingServices = append(hostingServices, data.NewEmbeddedETCDService(data.EmbeddedETCDServiceOptions{ClientConfigSink: options.DatabaseProviderOptions.ETCD.Client})) + if options.Config.Database.Provider == databaseprovider.TypeETCD && + options.Config.Database.ETCD.InMemory { + hostingServices = append(hostingServices, data.NewEmbeddedETCDService(data.EmbeddedETCDServiceOptions{ClientConfigSink: options.Config.Database.ETCD.Client})) } - options.MetricsProviderOptions.ServiceName = ServiceName - if options.MetricsProviderOptions.Prometheus.Enabled { + if options.Config.Metrics.Prometheus.Enabled { metricOptions := metricsservice.HostOptions{ - Config: &options.MetricsProviderOptions, + Config: &options.Config.Metrics, } hostingServices = append(hostingServices, metricsservice.NewService(metricOptions)) } - if options.ProfilerProviderOptions.Enabled { + if options.Config.Profiler.Enabled { profilerOptions := profilerservice.HostOptions{ - Config: &options.ProfilerProviderOptions, + Config: &options.Config.Profiler, } hostingServices = append(hostingServices, profilerservice.NewService(profilerOptions)) } - backendServiceOptions := hostopts.HostOptions{ - - Config: &hostopts.ProviderConfig{ - Env: hostopts.EnvironmentOptions{ - RoleLocation: options.Config.Location, - }, - DatabaseProvider: options.DatabaseProviderOptions, - SecretProvider: options.SecretProviderOptions, - QueueProvider: options.QueueProviderOptions, - MetricsProvider: options.MetricsProviderOptions, - TracerProvider: options.TracerProviderOptions, - ProfilerProvider: options.ProfilerProviderOptions, - }, - } - hostingServices = append(hostingServices, backend.NewService(backendServiceOptions, *options.Config)) - - options.TracerProviderOptions.ServiceName = "ucp" - hostingServices = append(hostingServices, &trace.Service{Options: options.TracerProviderOptions}) + hostingServices = append(hostingServices, &trace.Service{Options: options.Config.Tracing}) return &hosting.Host{ Services: hostingServices, diff --git a/pkg/ucp/testhost/doc.go b/pkg/ucp/testhost/doc.go new file mode 100644 index 0000000000..7b000bd986 --- /dev/null +++ b/pkg/ucp/testhost/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2023 The Radius Authors. + +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. +*/ + +// testhost provides an implementation of a test server for UCP. +package testhost diff --git a/pkg/ucp/testhost/host.go b/pkg/ucp/testhost/host.go new file mode 100644 index 0000000000..36077bc967 --- /dev/null +++ b/pkg/ucp/testhost/host.go @@ -0,0 +1,218 @@ +/* +Copyright 2023 The Radius Authors. + +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 testhost + +import ( + "context" + "fmt" + "strings" + "testing" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/armrpc/asyncoperation/statusmanager" + "github.com/radius-project/radius/pkg/armrpc/hostoptions" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" + "github.com/radius-project/radius/pkg/components/database" + "github.com/radius-project/radius/pkg/components/database/databaseprovider" + queue "github.com/radius-project/radius/pkg/components/queue" + "github.com/radius-project/radius/pkg/components/queue/queueprovider" + "github.com/radius-project/radius/pkg/components/secret" + "github.com/radius-project/radius/pkg/components/secret/secretprovider" + "github.com/radius-project/radius/pkg/components/testhost" + "github.com/radius-project/radius/pkg/sdk" + "github.com/radius-project/radius/pkg/ucp" + "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/config" + "github.com/radius-project/radius/pkg/ucp/frontend/modules" + "github.com/radius-project/radius/pkg/ucp/server" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// TestHostOption can be used to configure the UCP options before the server is started. +type TestHostOption interface { + // Apply applies the configuration to the UCP options. + Apply(options *ucp.Options) +} + +// TestHostOptionFunc is a function that implements the TestHostOption interface. +type TestHostOptionFunc func(options *ucp.Options) + +// Apply applies the function to the UCP options. +func (f TestHostOptionFunc) Apply(options *ucp.Options) { + f(options) +} + +// NoModules is a TestHostOption that disables all UCP modules. +func NoModules() TestHostOptionFunc { + return func(options *ucp.Options) { + options.Modules = []modules.Initializer{} + } +} + +// TestServerMocks provides access to mock instances created by the TestServer. +type TestServerMocks struct { + // DatabaseClient is the mock database client. + DatabaseClient *database.MockClient + + // DatabaseProvider is the mock database provider. + DatabaseProvider *databaseprovider.DatabaseProvider + + // QueueClient is the mock queue client. + QueueClient *queue.MockClient + + // QueueProvider is the mock queue provider. + QueueProvider *queueprovider.QueueProvider + + // SecretClient is the mock secret client. + SecretClient *secret.MockClient + + // SecretProvider is the mock secret provider. + SecretProvider *secretprovider.SecretProvider + + // StatusManager is the mock status manager. + StatusManager *statusmanager.MockStatusManager +} + +// NewMocks creates a new set of mocks for the test server. +func NewMocks(t *testing.T) *TestServerMocks { + ctrl := gomock.NewController(t) + databaseClient := database.NewMockClient(ctrl) + + queueClient := queue.NewMockClient(ctrl) + queueProvider := queueprovider.New(queueprovider.QueueProviderOptions{Name: "System.Resources"}) + queueProvider.SetClient(queueClient) + + secretClient := secret.NewMockClient(ctrl) + secretProvider := secretprovider.NewSecretProvider(secretprovider.SecretProviderOptions{}) + secretProvider.SetClient(secretClient) + + statusManager := statusmanager.NewMockStatusManager(ctrl) + return &TestServerMocks{ + DatabaseClient: databaseClient, + DatabaseProvider: databaseprovider.FromClient(databaseClient), + QueueClient: queueClient, + QueueProvider: queueProvider, + SecretClient: secretClient, + SecretProvider: secretProvider, + StatusManager: statusManager, + } +} + +// Apply updates the UCP options to use the mocks. +func (m *TestServerMocks) Apply(options *ucp.Options) { + options.SecretProvider = m.SecretProvider + options.DatabaseProvider = m.DatabaseProvider + options.QueueProvider = m.QueueProvider + options.StatusManager = m.StatusManager +} + +// TestHost provides a test host for the UCP server. +type TestHost struct { + *testhost.TestHost + options *ucp.Options + + clientFactoryUCP *v20231001preview.ClientFactory +} + +// Internals provides access to the internal options of the server. This allows tests +// to access the data stores and manipulate the server state. +func (th *TestHost) Options() *ucp.Options { + return th.options +} + +// UCP provides access to the generated clients for the UCP API. +func (ts *TestHost) UCP() *v20231001preview.ClientFactory { + if ts.clientFactoryUCP == nil { + connection, err := sdk.NewDirectConnection(ts.BaseURL()) + require.NoError(ts.T(), err) + + ts.clientFactoryUCP, err = v20231001preview.NewClientFactory(&aztoken.AnonymousCredential{}, sdk.NewClientOptions(connection)) + require.NoError(ts.T(), err) + } + + return ts.clientFactoryUCP +} + +// Start creates and starts a new TestServer. +func Start(t *testing.T, opts ...TestHostOption) *TestHost { + config := &ucp.Config{ + Database: databaseprovider.Options{ + Provider: databaseprovider.TypeInMemory, + }, + Environment: hostoptions.EnvironmentOptions{ + Name: "test", + RoleLocation: v1.LocationGlobal, + }, + Queue: queueprovider.QueueProviderOptions{ + Provider: queueprovider.TypeInmemory, + Name: "ucp", + }, + Secrets: secretprovider.SecretProviderOptions{ + Provider: secretprovider.TypeInMemorySecret, + }, + Server: hostoptions.ServerOptions{ + // Initialized dynamically when the server is started. + }, + + UCP: config.UCPOptions{ + Kind: config.UCPConnectionKindDirect, + Direct: &config.UCPDirectConnectionOptions{ + Endpoint: "http://localhost:65000", // Initialized dynamically when the server is started. + }, + }, + } + + options, err := ucp.NewOptions(context.Background(), config) + require.NoError(t, err) + + for _, opt := range opts { + opt.Apply(options) + } + + return StartWithOptions(t, options) + +} + +func StartWithOptions(t *testing.T, options *ucp.Options) *TestHost { + options.Config.Server.Host = "localhost" + if options.Config.Server.Port == 0 { + options.Config.Server.Port = testhost.AllocateFreePort(t) + } + + baseURL := fmt.Sprintf( + "http://%s%s", + options.Config.Server.Address(), + options.Config.Server.PathBase) + baseURL = strings.TrimSuffix(baseURL, "/") + + options.Config.UCP.Kind = config.UCPConnectionKindDirect + options.Config.UCP.Direct = &config.UCPDirectConnectionOptions{Endpoint: baseURL} + + // Instantiate the UCP client now that we know the URL. + var err error + options.UCP, err = sdk.NewDirectConnection(baseURL) + require.NoError(t, err) + + host, err := server.NewServer(options) + require.NoError(t, err, "failed to create server") + + return &TestHost{ + TestHost: testhost.StartHost(t, host, baseURL), + options: options, + } +}