diff --git a/cmd/rad/cmd/bicep.go b/cmd/rad/cmd/bicep.go index f673ee770d..ea615cd53d 100644 --- a/cmd/rad/cmd/bicep.go +++ b/cmd/rad/cmd/bicep.go @@ -22,8 +22,8 @@ import ( var bicepCmd = &cobra.Command{ Use: "bicep", - Short: "Manage bicep compiler", - Long: `Manage bicep compiler used by Radius`, + Short: "Handle bicep-specific tasks for Radius", + Long: `Handle bicep-specific tasks for Radius`, } func init() { diff --git a/cmd/rad/cmd/root.go b/cmd/rad/cmd/root.go index e360d5ba00..f7357bab19 100644 --- a/cmd/rad/cmd/root.go +++ b/cmd/rad/cmd/root.go @@ -35,6 +35,7 @@ import ( app_list "github.com/radius-project/radius/pkg/cli/cmd/app/list" app_show "github.com/radius-project/radius/pkg/cli/cmd/app/show" app_status "github.com/radius-project/radius/pkg/cli/cmd/app/status" + bicep_generate_kubernetes_manifest "github.com/radius-project/radius/pkg/cli/cmd/bicep/generatekubernetesmanifest" bicep_publish "github.com/radius-project/radius/pkg/cli/cmd/bicep/publish" bicep_publishextension "github.com/radius-project/radius/pkg/cli/cmd/bicep/publishextension" credential "github.com/radius-project/radius/pkg/cli/cmd/credential" @@ -349,6 +350,9 @@ func initSubCommands() { bicepPublishCmd, _ := bicep_publish.NewCommand(framework) bicepCmd.AddCommand(bicepPublishCmd) + bicepGenerateKubernetesManifestCmd, _ := bicep_generate_kubernetes_manifest.NewCommand(framework) + bicepCmd.AddCommand(bicepGenerateKubernetesManifestCmd) + bicepPublishExtensionCmd, _ := bicep_publishextension.NewCommand(framework) bicepCmd.AddCommand(bicepPublishExtensionCmd) diff --git a/deploy/Chart/crds/radius/radapp.io_deploymentresources.yaml b/deploy/Chart/crds/radius/radapp.io_deploymentresources.yaml new file mode 100644 index 0000000000..cea26a1b55 --- /dev/null +++ b/deploy/Chart/crds/radius/radapp.io_deploymentresources.yaml @@ -0,0 +1,90 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.0 + name: deploymentresources.radapp.io +spec: + group: radapp.io + names: + categories: + - all + - radius + kind: DeploymentResource + listKind: DeploymentResourceList + plural: deploymentresources + singular: deploymentresource + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Status of the resource + jsonPath: .status.phrase + name: Status + type: string + name: v1alpha3 + schema: + openAPIV3Schema: + description: DeploymentResource is the Schema for the DeploymentResources + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: DeploymentResourceSpec defines the desired state of a DeploymentResource + resource. + properties: + id: + description: Id is the resource id of the Radius resource. + type: string + type: object + status: + description: DeploymentResourceStatus defines the observed state of a + DeploymentResource resource. + properties: + id: + description: Id is the resource id of the Radius resource. + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this DeploymentResource. + format: int64 + type: integer + operation: + description: Operation tracks the status of an in-progress provisioning + operation. + properties: + operationKind: + description: OperationKind describes the type of operation being + performed. + type: string + resumeToken: + description: ResumeToken is a token that can be used to resume + an in-progress provisioning operation. + type: string + type: object + phrase: + description: Phrase indicates the current status of the Deployment + Resource. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/Chart/crds/radius/radapp.io_deploymenttemplates.yaml b/deploy/Chart/crds/radius/radapp.io_deploymenttemplates.yaml new file mode 100644 index 0000000000..e520a0a67e --- /dev/null +++ b/deploy/Chart/crds/radius/radapp.io_deploymenttemplates.yaml @@ -0,0 +1,106 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.0 + name: deploymenttemplates.radapp.io +spec: + group: radapp.io + names: + categories: + - all + - radius + kind: DeploymentTemplate + listKind: DeploymentTemplateList + plural: deploymenttemplates + singular: deploymenttemplate + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Status of the resource + jsonPath: .status.phrase + name: Status + type: string + name: v1alpha3 + schema: + openAPIV3Schema: + description: DeploymentTemplate is the Schema for the deploymenttemplates + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: DeploymentTemplateSpec defines the desired state of a DeploymentTemplate + resource. + properties: + parameters: + additionalProperties: + type: string + description: Parameters is the ARM JSON parameters for the template. + type: object + providerConfig: + description: ProviderConfig specifies the scopes for resources. + type: string + template: + description: Template is the ARM JSON manifest that defines the resources + to deploy. + type: string + type: object + status: + description: DeploymentTemplateStatus defines the observed state of a + DeploymentTemplate resource. + properties: + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this DeploymentTemplate. + format: int64 + type: integer + operation: + description: Operation tracks the status of an in-progress provisioning + operation. + properties: + operationKind: + description: OperationKind describes the type of operation being + performed. + type: string + resumeToken: + description: ResumeToken is a token that can be used to resume + an in-progress provisioning operation. + type: string + type: object + outputResources: + description: OutputResources is a list of the resourceIDs that were + created by the template on the last deployment. + items: + type: string + type: array + phrase: + description: Phrase indicates the current status of the Deployment + Template. + type: string + statusHash: + description: StatusHash is a hash of the DeploymentTemplate's state + (template, parameters, and provider config). + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/Chart/templates/controller/rbac.yaml b/deploy/Chart/templates/controller/rbac.yaml index b060d31744..db554aa8d5 100644 --- a/deploy/Chart/templates/controller/rbac.yaml +++ b/deploy/Chart/templates/controller/rbac.yaml @@ -38,6 +38,10 @@ rules: resources: - recipes - recipes/status + - deploymenttemplates + - deploymenttemplates/status + - deploymentresources + - deploymentresources/status verbs: - create - delete diff --git a/pkg/cli/bicep/deployment_parameters.go b/pkg/cli/bicep/deployment_parameters.go index ceede46273..a88275be13 100644 --- a/pkg/cli/bicep/deployment_parameters.go +++ b/pkg/cli/bicep/deployment_parameters.go @@ -19,32 +19,22 @@ package bicep import ( "encoding/json" "fmt" - "io/fs" - "os" "strings" "github.com/radius-project/radius/pkg/cli/clients" + "github.com/radius-project/radius/pkg/cli/filesystem" ) // ParameterParser is used to parse the parameters as part of the `rad deploy` command. See the docs for `rad deploy` for examples // of what we need to support here. type ParameterParser struct { - FileSystem fs.FS -} - -type OSFileSystem struct { + FileSystem filesystem.FileSystem } type ParameterFile struct { Parameters clients.DeploymentParameters `json:"parameters"` } -// The Open function opens the file specified by the name parameter and returns a file object and an error if the file -// cannot be opened. -func (OSFileSystem) Open(name string) (fs.File, error) { - return os.Open(name) -} - // ParseFileContents takes in a map of strings and any type and returns a DeploymentParameters object and // an error if one occurs during the process. func (pp ParameterParser) ParseFileContents(input map[string]any) (clients.DeploymentParameters, error) { @@ -90,7 +80,7 @@ func (pp ParameterParser) parseSingle(input string, output clients.DeploymentPar if strings.HasPrefix(input, "@") { // input is a file that declares multiple parameters filePath := strings.TrimPrefix(input, "@") - b, err := fs.ReadFile(pp.FileSystem, filePath) + b, err := pp.FileSystem.ReadFile(filePath) if err != nil { return err } @@ -111,7 +101,7 @@ func (pp ParameterParser) parseSingle(input string, output clients.DeploymentPar if strings.HasPrefix(parameterValue, "@") { // input is a file that declares a single parameter filePath := strings.TrimPrefix(parameterValue, "@") - b, err := fs.ReadFile(pp.FileSystem, filePath) + b, err := pp.FileSystem.ReadFile(filePath) if err != nil { return err } diff --git a/pkg/cli/bicep/deployment_parameters_test.go b/pkg/cli/bicep/deployment_parameters_test.go index f348240984..25770b2dc7 100644 --- a/pkg/cli/bicep/deployment_parameters_test.go +++ b/pkg/cli/bicep/deployment_parameters_test.go @@ -24,6 +24,7 @@ import ( "testing/fstest" "github.com/radius-project/radius/pkg/cli/clients" + "github.com/radius-project/radius/pkg/cli/filesystem" "github.com/stretchr/testify/require" ) @@ -36,7 +37,7 @@ func Test_Parameters_Invalid(t *testing.T) { } parser := ParameterParser{ - FileSystem: fstest.MapFS{}, + FileSystem: filesystem.NewMemMapFileSystem(), } for _, input := range inputs { @@ -56,13 +57,16 @@ func Test_ParseParameters_Overwrite(t *testing.T) { "key3=value3", } + // Initialize the ParameterParser with the in-memory filesystem parser := ParameterParser{ - FileSystem: fstest.MapFS{ - "many.json": { - Data: []byte(`{ "parameters": { "key1": { "value": { "someValue": true } }, "key2": { "value": "overridden-value" } } }`), - }, - "single.json": { - Data: []byte(`{ "someValue": "another-value" }`), + FileSystem: filesystem.MemMapFileSystem{ + InternalFileSystem: fstest.MapFS{ + "many.json": { + Data: []byte(`{ "parameters": { "key1": { "value": { "someValue": true } }, "key2": { "value": "overridden-value" } } }`), + }, + "single.json": { + Data: []byte(`{ "someValue": "another-value" }`), + }, }, }, } @@ -91,7 +95,7 @@ func Test_ParseParameters_Overwrite(t *testing.T) { func Test_ParseParameters_File(t *testing.T) { parser := ParameterParser{ - FileSystem: fstest.MapFS{}, + FileSystem: filesystem.NewMemMapFileSystem(), } input, err := os.ReadFile(filepath.Join("testdata", "test-parameters.json")) diff --git a/pkg/cli/cmd/bicep/generatekubernetesmanifest/generatekubernetesmanifest.go b/pkg/cli/cmd/bicep/generatekubernetesmanifest/generatekubernetesmanifest.go new file mode 100644 index 0000000000..73a5a2ca41 --- /dev/null +++ b/pkg/cli/cmd/bicep/generatekubernetesmanifest/generatekubernetesmanifest.go @@ -0,0 +1,239 @@ +/* +Copyright 2024 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 bicep + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + + "github.com/radius-project/radius/pkg/cli/bicep" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/connections" + "github.com/radius-project/radius/pkg/cli/deploy" + "github.com/radius-project/radius/pkg/cli/filesystem" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +const ( + resourceGroupRequiredMessage = "Radius resource group is required. Please provide a value for the --group (-g) flag." +) + +// NewCommand creates a command for the `rad bicep generate-kubernetes-manifest` command. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "generate-kubernetes-manifest [file]", + Short: "Generate a DeploymentTemplate Custom Resource.", + Long: `Generate a DeploymentTemplate Custom Resource. + + This command compiles a Bicep template with the given parameters and outputs a DeploymentTemplate Custom Resource. + + You can specify parameters using the '--parameter' flag ('-p' for short). Parameters can be passed as: + + - A file containing multiple parameters using the ARM JSON parameter format (see below) + - A file containing a single value in JSON format + - A key-value-pair passed in the command line + + When passing multiple parameters in a single file, use the format described here: + + https://docs.microsoft.com/en-us/azure/azure-resource-manager/templates/parameter-files + + You can specify parameters using multiple sources. Parameters can be overridden based on the + order they are provided. Parameters appearing later in the argument list will override those defined earlier. + `, + Example: ` +# Generate a DeploymentTemplate Custom Resource from a Bicep file. +rad bicep generate-kubernetes-manifest app.bicep --parameters @app.bicepparam --parameters tag=latest --destination-file app.yaml --resource-group default + `, + Args: cobra.ExactArgs(1), + RunE: framework.RunCommand(runner), + } + + commonflags.AddResourceGroupFlag(cmd) + commonflags.AddParameterFlag(cmd) + + cmd.Flags().StringP("destination-file", "d", "", "Path of the generated DeploymentTemplate yaml file created by running this command.") + _ = cmd.MarkFlagFilename("destination-file", ".yaml", ".yml") + + cmd.Flags().String("azure-scope", "", "Scope for Azure deployment.") + cmd.Flags().String("aws-scope", "", "Scope for AWS deployment.") + + return cmd, runner +} + +// Runner is the runner implementation for the `rad bicep generate-kubernetes` command. +type Runner struct { + Bicep bicep.Interface + ConfigHolder *framework.ConfigHolder + ConnectionFactory connections.Factory + Deploy deploy.Interface + Output output.Interface + + FileSystem filesystem.FileSystem + Group string + FilePath string + Parameters map[string]map[string]any + DestinationFile string + AzureScope string + AWSScope string +} + +// NewRunner creates a new instance of the `rad bicep generate-kubernetes-manifest` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + Bicep: factory.GetBicep(), + ConnectionFactory: factory.GetConnectionFactory(), + ConfigHolder: factory.GetConfigHolder(), + Deploy: factory.GetDeploy(), + Output: factory.GetOutput(), + } +} + +// Validate validates the inputs of the rad bicep generate-kubernetes-manifest command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + r.FilePath = args[0] + + var err error + r.Group, err = cmd.Flags().GetString("group") + if err != nil { + return err + } + + if r.Group == "" { + return clierrors.Message(resourceGroupRequiredMessage) + } + + r.AzureScope, err = cmd.Flags().GetString("azure-scope") + if err != nil { + return err + } + + r.AWSScope, err = cmd.Flags().GetString("aws-scope") + if err != nil { + return err + } + + r.DestinationFile, err = cmd.Flags().GetString("destination-file") + if err != nil { + return err + } + + // If the destination file is not provided, use the base name of the file with a .yaml extension + if r.DestinationFile == "" { + r.DestinationFile = strings.TrimSuffix(filepath.Base(r.FilePath), filepath.Ext(r.FilePath)) + ".yaml" + } + + if filepath.Ext(r.DestinationFile) != ".yaml" && filepath.Ext(r.DestinationFile) != ".yml" { + return clierrors.Message("Destination file must have a .yaml or .yml extension") + } + + parameterArgs, err := cmd.Flags().GetStringArray("parameters") + if err != nil { + return err + } + + if r.FileSystem == nil { + r.FileSystem = filesystem.NewOSFS() + } + + parser := bicep.ParameterParser{FileSystem: r.FileSystem} + r.Parameters, err = parser.Parse(parameterArgs...) + if err != nil { + return err + } + + return nil +} + +// Run runs the rad bicep generate-kubernetes-manifest command. +func (r *Runner) Run(ctx context.Context) error { + template, err := r.Bicep.PrepareTemplate(r.FilePath) + if err != nil { + return err + } + + deploymentTemplate, err := r.generateDeploymentTemplate(filepath.Base(r.FilePath), template, r.Parameters) + if err != nil { + return err + } + + err = r.createDeploymentTemplateYAMLFile(deploymentTemplate) + if err != nil { + return err + } + + r.Output.LogInfo("DeploymentTemplate file created at %s", r.DestinationFile) + + return nil +} + +// generateDeploymentTemplate generates a DeploymentTemplate Custom Resource from the given template and parameters. +func (r *Runner) generateDeploymentTemplate(fileName string, template map[string]any, parameters map[string]map[string]any) (map[string]any, error) { + marshalledTemplate, err := json.MarshalIndent(template, "", " ") + if err != nil { + return nil, err + } + + providerConfig, err := sdkclients.GenerateProviderConfig(r.Group, r.AWSScope, r.AzureScope).String() + if err != nil { + return nil, err + } + + params := make(map[string]string) + for k, v := range parameters { + params[k] = v["value"].(string) + } + + deploymentTemplate := map[string]any{ + "kind": "DeploymentTemplate", + "apiVersion": "radapp.io/v1alpha3", + "metadata": map[string]any{ + "name": fileName, + }, + "spec": map[string]any{ + "template": string(marshalledTemplate), + "parameters": params, + "providerConfig": providerConfig, + }, + } + + return deploymentTemplate, nil +} + +// createDeploymentTemplateYAMLFile creates a DeploymentTemplate YAML file with the given content. +func (r *Runner) createDeploymentTemplateYAMLFile(deploymentTemplate map[string]any) error { + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + + encoder.SetIndent(2) + + err := encoder.Encode(deploymentTemplate) + if err != nil { + return err + } + + return r.FileSystem.WriteFile(r.DestinationFile, buf.Bytes(), 0644) +} diff --git a/pkg/cli/cmd/bicep/generatekubernetesmanifest/generatekubernetesmanifest_test.go b/pkg/cli/cmd/bicep/generatekubernetesmanifest/generatekubernetesmanifest_test.go new file mode 100644 index 0000000000..9a898dae5b --- /dev/null +++ b/pkg/cli/cmd/bicep/generatekubernetesmanifest/generatekubernetesmanifest_test.go @@ -0,0 +1,202 @@ +/* +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 bicep + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/radius-project/radius/pkg/cli/bicep" + "github.com/radius-project/radius/pkg/cli/filesystem" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/test/radcli" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + testcases := []radcli.ValidateInput{ + { + Name: "rad bicep generate-kubernetes-manifest - valid with group short flag", + Input: []string{"app.bicep", "-g", "default"}, + ExpectedValid: true, + ValidateCallback: func(t *testing.T, r framework.Runner) { + runner := r.(*Runner) + require.Equal(t, "default", runner.Group) + }, + }, + { + Name: "rad bicep generate-kubernetes-manifest - valid with group long flag", + Input: []string{"app.bicep", "--group", "default"}, + ExpectedValid: true, + ValidateCallback: func(t *testing.T, r framework.Runner) { + runner := r.(*Runner) + require.Equal(t, "default", runner.Group) + }, + }, + { + Name: "rad bicep generate-kubernetes-manifest - invalid with no group provided", + Input: []string{"app.bicep"}, + ExpectedValid: false, + }, + { + Name: "rad bicep generate-kubernetes-manifest - valid with parameters", + Input: []string{"app.bicep", "-g", "default", "-p", "foo=bar", "--parameters", "a=b", "--parameters", "@testdata/parameters.json"}, + ExpectedValid: true, + ValidateCallback: func(t *testing.T, r framework.Runner) { + runner := r.(*Runner) + expectedParameters := map[string]map[string]any{ + "foo": { + "value": "bar", + }, + "a": { + "value": "b", + }, + "b": { + "value": "c", + }, + } + require.Equal(t, expectedParameters, runner.Parameters) + }, + }, + { + Name: "rad bicep generate-kubernetes-manifest - invalid parameter format", + Input: []string{"app.bicep", "-g", "default", "--parameters", "invalid-format"}, + ExpectedValid: false, + }, + { + Name: "rad bicep generate-kubernetes-manifest - missing file argument", + Input: []string{}, + ExpectedValid: false, + }, + { + Name: "rad bicep generate-kubernetes-manifest - too many args", + Input: []string{"app.bicep", "-g", "default", "anotherfile.bicep"}, + ExpectedValid: false, + }, + { + Name: "rad bicep generate-kubernetes-manifest - valid with destination file long flag", + Input: []string{"app.bicep", "-g", "default", "--destination-file", "test.yaml"}, + ExpectedValid: true, + ValidateCallback: func(t *testing.T, r framework.Runner) { + runner := r.(*Runner) + require.Equal(t, "test.yaml", runner.DestinationFile) + }, + }, + { + Name: "rad bicep generate-kubernetes-manifest - valid with destination file short flag", + Input: []string{"app.bicep", "-g", "default", "-d", "test.yaml"}, + ExpectedValid: true, + ValidateCallback: func(t *testing.T, r framework.Runner) { + runner := r.(*Runner) + require.Equal(t, "test.yaml", runner.DestinationFile) + }, + }, + { + Name: "rad bicep generate-kubernetes-manifest - invalid destination file", + Input: []string{"app.bicep", "-g", "default", "--destination-file", "test.json"}, + ExpectedValid: false, + }, + { + Name: "rad bicep generate-kubernetes-manifest - valid with azure scope", + Input: []string{"app.bicep", "-g", "default", "--azure-scope", "azure-scope-value"}, + ExpectedValid: true, + }, + { + Name: "rad bicep generate-kubernetes-manifest - valid with aws scope", + Input: []string{"app.bicep", "-g", "default", "--aws-scope", "aws-scope-value"}, + ExpectedValid: true, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run(t *testing.T) { + t.Run("Create DeploymentTemplate", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + resourceGroup := "default" + testName := "deploymenttemplate" + bicepFilePath := fmt.Sprintf("%s.bicep", testName) + parametersFilePath := fmt.Sprintf("%s-parameters.json", testName) + jsonFilePath := fmt.Sprintf("%s.json", testName) + yamlFilePath := fmt.Sprintf("%s.yaml", testName) + + template, err := os.ReadFile(filepath.Join("testdata", testName, jsonFilePath)) + require.NoError(t, err) + + var templateMap map[string]any + err = json.Unmarshal([]byte(template), &templateMap) + require.NoError(t, err) + + parameters, err := os.ReadFile(filepath.Join("testdata", testName, parametersFilePath)) + require.NoError(t, err) + + var parametersMap map[string]map[string]any + err = json.Unmarshal([]byte(parameters), ¶metersMap) + require.NoError(t, err) + + bicep := bicep.NewMockInterface(ctrl) + bicep.EXPECT(). + PrepareTemplate(bicepFilePath). + Return(templateMap, nil). + Times(1) + + outputSink := &output.MockOutput{} + runner := &Runner{ + Bicep: bicep, + Output: outputSink, + FilePath: bicepFilePath, + Parameters: parametersMap, + FileSystem: filesystem.NewMemMapFileSystem(), + DestinationFile: yamlFilePath, + Group: resourceGroup, + } + + fileExists := runner.FileSystem.Exists(yamlFilePath) + require.NoError(t, err) + require.False(t, fileExists) + + err = runner.Run(context.Background()) + require.NoError(t, err) + + fileExists = runner.FileSystem.Exists(yamlFilePath) + require.NoError(t, err) + require.True(t, fileExists) + + require.Equal(t, yamlFilePath, runner.DestinationFile) + + expected, err := os.ReadFile(filepath.Join("testdata", testName, yamlFilePath)) + require.NoError(t, err) + + actual, err := runner.FileSystem.ReadFile(yamlFilePath) + require.NoError(t, err) + require.Equal(t, string(expected), string(actual)) + }) +} diff --git a/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate-parameters.json b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate-parameters.json new file mode 100644 index 0000000000..4f773154ac --- /dev/null +++ b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate-parameters.json @@ -0,0 +1,5 @@ +{ + "tag": { + "value": "v1.0.0" + } +} diff --git a/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate.bicep b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate.bicep new file mode 100644 index 0000000000..8af9edc02f --- /dev/null +++ b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate.bicep @@ -0,0 +1,23 @@ +extension radius + +param tag string = 'latest' +param kubernetesNamespace string = 'default' + +resource parameters 'Applications.Core/environments@2023-10-01-preview' = { + name: 'parameters' + properties: { + compute: { + kind: 'kubernetes' + resourceId: 'self' + namespace: kubernetesNamespace + } + recipes: { + 'Applications.Datastores/redisCaches': { + default: { + templateKind: 'bicep' + templatePath: 'ghcr.io/myregistry:${tag}' + } + } + } + } +} diff --git a/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate.json b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate.json new file mode 100644 index 0000000000..f1698cc4db --- /dev/null +++ b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.32.4.45862", + "templateHash": "16344337442844554850" + } + }, + "parameters": { + "tag": { + "type": "string", + "defaultValue": "latest" + }, + "kubernetesNamespace": { + "type": "string", + "defaultValue": "default" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "parameters": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "parameters", + "properties": { + "compute": { + "kind": "kubernetes", + "resourceId": "self", + "namespace": "[parameters('kubernetesNamespace')]" + }, + "recipes": { + "Applications.Datastores/redisCaches": { + "default": { + "templateKind": "bicep", + "templatePath": "[format('ghcr.io/myregistry:{0}', parameters('tag'))]" + } + } + } + } + } + } + } +} diff --git a/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate.yaml b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate.yaml new file mode 100644 index 0000000000..c7c0662d74 --- /dev/null +++ b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/deploymenttemplate/deploymenttemplate.yaml @@ -0,0 +1,79 @@ +apiVersion: radapp.io/v1alpha3 +kind: DeploymentTemplate +metadata: + name: deploymenttemplate.bicep +spec: + parameters: + tag: v1.0.0 + providerConfig: |- + { + "radius": { + "type": "Radius", + "value": { + "scope": "/planes/radius/local/resourceGroups/default" + } + }, + "deployments": { + "type": "Microsoft.Resources", + "value": { + "scope": "/planes/radius/local/resourceGroups/default" + } + } + } + template: |- + { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "languageVersion": "2.1-experimental", + "metadata": { + "_EXPERIMENTAL_FEATURES_ENABLED": [ + "Extensibility" + ], + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_generator": { + "name": "bicep", + "templateHash": "16344337442844554850", + "version": "0.32.4.45862" + } + }, + "parameters": { + "kubernetesNamespace": { + "defaultValue": "default", + "type": "string" + }, + "tag": { + "defaultValue": "latest", + "type": "string" + } + }, + "resources": { + "parameters": { + "import": "Radius", + "properties": { + "name": "parameters", + "properties": { + "compute": { + "kind": "kubernetes", + "namespace": "[parameters('kubernetesNamespace')]", + "resourceId": "self" + }, + "recipes": { + "Applications.Datastores/redisCaches": { + "default": { + "templateKind": "bicep", + "templatePath": "[format('ghcr.io/myregistry:{0}', parameters('tag'))]" + } + } + } + } + }, + "type": "Applications.Core/environments@2023-10-01-preview" + } + } + } diff --git a/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/parameters.json b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/parameters.json new file mode 100644 index 0000000000..cb278882b6 --- /dev/null +++ b/pkg/cli/cmd/bicep/generatekubernetesmanifest/testdata/parameters.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "b": { + "value": "c" + } + } +} diff --git a/pkg/cli/cmd/deploy/deploy.go b/pkg/cli/cmd/deploy/deploy.go index e651d96be6..e4377ca992 100644 --- a/pkg/cli/cmd/deploy/deploy.go +++ b/pkg/cli/cmd/deploy/deploy.go @@ -30,6 +30,7 @@ import ( "github.com/radius-project/radius/pkg/cli/cmd/commonflags" "github.com/radius-project/radius/pkg/cli/connections" "github.com/radius-project/radius/pkg/cli/deploy" + "github.com/radius-project/radius/pkg/cli/filesystem" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/output" "github.com/radius-project/radius/pkg/cli/workspaces" @@ -69,7 +70,7 @@ When passing multiple parameters in a single file, use the format described here https://docs.microsoft.com/en-us/azure/azure-resource-manager/templates/parameter-files You can specify parameters using multiple sources. Parameters can be overridden based on the -order the are provided. Parameters appearing later in the argument list will override those defined earlier. +order they are provided. Parameters appearing later in the argument list will override those defined earlier. `, Example: ` # deploy a Bicep template @@ -235,7 +236,7 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { return err } - parser := bicep.ParameterParser{FileSystem: bicep.OSFileSystem{}} + parser := bicep.ParameterParser{FileSystem: filesystem.NewOSFS()} r.Parameters, err = parser.Parse(parameterArgs...) if err != nil { return err diff --git a/pkg/cli/cmd/recipe/register/register.go b/pkg/cli/cmd/recipe/register/register.go index b12081a16a..7d4682a9ca 100644 --- a/pkg/cli/cmd/recipe/register/register.go +++ b/pkg/cli/cmd/recipe/register/register.go @@ -24,6 +24,7 @@ import ( "github.com/radius-project/radius/pkg/cli/clierrors" "github.com/radius-project/radius/pkg/cli/cmd/commonflags" "github.com/radius-project/radius/pkg/cli/connections" + "github.com/radius-project/radius/pkg/cli/filesystem" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/output" "github.com/radius-project/radius/pkg/cli/workspaces" @@ -148,7 +149,7 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { return err } - parser := bicep.ParameterParser{FileSystem: bicep.OSFileSystem{}} + parser := bicep.ParameterParser{FileSystem: filesystem.NewOSFS()} r.Parameters, err = parser.Parse(parameterArgs...) if err != nil { return err diff --git a/pkg/cli/deployment/deploy.go b/pkg/cli/deployment/deploy.go index b00bd0557d..c22b7e7404 100644 --- a/pkg/cli/deployment/deploy.go +++ b/pkg/cli/deployment/deploy.go @@ -24,7 +24,6 @@ import ( "sync" "time" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/google/uuid" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" @@ -49,7 +48,7 @@ const ( type ResourceDeploymentClient struct { RadiusResourceGroup string - Client *sdkclients.ResourceDeploymentsClient + Client sdkclients.ResourceDeploymentsClient OperationsClient *sdkclients.ResourceDeploymentOperationsClient Tags map[string]*string } @@ -94,7 +93,7 @@ func (dc *ResourceDeploymentClient) Deploy(ctx context.Context, options clients. return summary, nil } -func (dc *ResourceDeploymentClient) startDeployment(ctx context.Context, name string, options clients.DeploymentOptions) (*runtime.Poller[sdkclients.ClientCreateOrUpdateResponse], error) { +func (dc *ResourceDeploymentClient) startDeployment(ctx context.Context, name string, options clients.DeploymentOptions) (sdkclients.Poller[sdkclients.ClientCreateOrUpdateResponse], error) { var resourceId string scopes := []ucpresources.ScopeSegment{ { @@ -197,8 +196,8 @@ func (dc *ResourceDeploymentClient) createSummary(deployment *armresources.Deplo return clients.DeploymentResult{Resources: resources, Outputs: outputs}, nil } -func (dc *ResourceDeploymentClient) waitForCompletion(ctx context.Context, poller *runtime.Poller[sdkclients.ClientCreateOrUpdateResponse]) (clients.DeploymentResult, error) { - resp, err := poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: deploymentPollInterval}) +func (dc *ResourceDeploymentClient) waitForCompletion(ctx context.Context, poller sdkclients.Poller[sdkclients.ClientCreateOrUpdateResponse]) (clients.DeploymentResult, error) { + resp, err := poller.PollUntilDone(ctx, &sdkclients.PollUntilDoneOptions{Frequency: deploymentPollInterval}) if err != nil { return clients.DeploymentResult{}, err } diff --git a/pkg/cli/deployment/deploy_test.go b/pkg/cli/deployment/deploy_test.go index 6b13080986..aaab3acf3f 100644 --- a/pkg/cli/deployment/deploy_test.go +++ b/pkg/cli/deployment/deploy_test.go @@ -54,8 +54,6 @@ func Test_GetProviderConfigs(t *testing.T) { func Test_GetProviderConfigsWithAzProvider(t *testing.T) { resourceDeploymentClient := ResourceDeploymentClient{ RadiusResourceGroup: "testrg", - Client: &sdkclients.ResourceDeploymentsClient{}, - OperationsClient: &sdkclients.ResourceDeploymentOperationsClient{}, } options := clients.DeploymentOptions{ diff --git a/pkg/cli/filesystem/filesystem.go b/pkg/cli/filesystem/filesystem.go new file mode 100644 index 0000000000..1b40ecd88c --- /dev/null +++ b/pkg/cli/filesystem/filesystem.go @@ -0,0 +1,31 @@ +/* +Copyright 2024 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 filesystem + +import ( + "io/fs" +) + +// FileSystem is an interface that defines the methods needed to interact with a file system. +type FileSystem interface { + Create(name string) (fs.File, error) + Exists(name string) bool + Open(name string) (fs.File, error) + ReadFile(name string) ([]byte, error) + Stat(name string) (fs.FileInfo, error) + WriteFile(name string, data []byte, perm fs.FileMode) error +} diff --git a/pkg/cli/filesystem/memmapfs.go b/pkg/cli/filesystem/memmapfs.go new file mode 100644 index 0000000000..32572eb1ae --- /dev/null +++ b/pkg/cli/filesystem/memmapfs.go @@ -0,0 +1,68 @@ +/* +Copyright 2024 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 filesystem + +import ( + "io/fs" + "testing/fstest" +) + +// MemMapFileSystem is an implementation of the FileSystem interface that uses an in-memory map to store files. +// It uses the methods from the fstest package to interact with the in-memory map. +type MemMapFileSystem struct { + InternalFileSystem fstest.MapFS +} + +var _ FileSystem = (*MemMapFileSystem)(nil) + +func NewMemMapFileSystem() *MemMapFileSystem { + return &MemMapFileSystem{ + InternalFileSystem: fstest.MapFS{}, + } +} + +func (mmfs MemMapFileSystem) Create(name string) (fs.File, error) { + mmfs.InternalFileSystem[name] = &fstest.MapFile{} + + return mmfs.InternalFileSystem.Open(name) +} + +func (mmfs MemMapFileSystem) Exists(name string) bool { + _, ok := mmfs.InternalFileSystem[name] + return ok +} + +func (mmfs MemMapFileSystem) Open(name string) (fs.File, error) { + return mmfs.InternalFileSystem.Open(name) +} + +func (mmfs MemMapFileSystem) ReadFile(name string) ([]byte, error) { + return mmfs.InternalFileSystem.ReadFile(name) +} + +func (mmfs MemMapFileSystem) Stat(name string) (fs.FileInfo, error) { + return mmfs.InternalFileSystem.Stat(name) +} + +func (mmfs MemMapFileSystem) WriteFile(name string, data []byte, perm fs.FileMode) error { + mmfs.InternalFileSystem[name] = &fstest.MapFile{ + Data: data, + Mode: perm, + } + + return nil +} diff --git a/pkg/cli/filesystem/memmapfs_test.go b/pkg/cli/filesystem/memmapfs_test.go new file mode 100644 index 0000000000..87adc9cb31 --- /dev/null +++ b/pkg/cli/filesystem/memmapfs_test.go @@ -0,0 +1,99 @@ +/* +Copyright 2024 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 filesystem + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewMemMapFileSystem(t *testing.T) { + fs := NewMemMapFileSystem() + require.NotNil(t, fs) +} + +func TestMemMapFileSystem_Create(t *testing.T) { + fs := NewMemMapFileSystem() + fileName := "testfile" + + file, err := fs.Create(fileName) + require.NoError(t, err) + require.NotNil(t, file) + require.True(t, fs.Exists(fileName)) +} + +func TestMemMapFileSystem_Exists(t *testing.T) { + fs := NewMemMapFileSystem() + fileName := "testfile" + + require.False(t, fs.Exists(fileName)) + + _, _ = fs.Create(fileName) + + require.True(t, fs.Exists(fileName)) +} + +func TestMemMapFileSystem_Open(t *testing.T) { + fs := NewMemMapFileSystem() + fileName := "testfile" + + _, _ = fs.Create(fileName) + + file, err := fs.Open(fileName) + require.NoError(t, err) + require.NotNil(t, file) +} + +func TestMemMapFileSystem_ReadFile(t *testing.T) { + fs := NewMemMapFileSystem() + fileName := "testfile" + data := []byte("hello world") + + err := fs.WriteFile(fileName, data, os.ModePerm) + require.NoError(t, err) + + readData, err := fs.ReadFile(fileName) + require.NoError(t, err) + require.Equal(t, data, readData) +} + +func TestMemMapFileSystem_Stat(t *testing.T) { + fs := NewMemMapFileSystem() + fileName := "testfile" + + _, _ = fs.Create(fileName) + + info, err := fs.Stat(fileName) + require.NoError(t, err) + require.NotNil(t, info) + require.Equal(t, fileName, info.Name()) +} + +func TestMemMapFileSystem_WriteFile(t *testing.T) { + fs := NewMemMapFileSystem() + fileName := "testfile" + data := []byte("hello world") + + err := fs.WriteFile(fileName, data, os.ModePerm) + require.NoError(t, err) + + readData, err := fs.ReadFile(fileName) + require.NoError(t, err) + require.Equal(t, data, readData) +} diff --git a/pkg/cli/filesystem/osfs.go b/pkg/cli/filesystem/osfs.go new file mode 100644 index 0000000000..771c9fba0f --- /dev/null +++ b/pkg/cli/filesystem/osfs.go @@ -0,0 +1,57 @@ +/* +Copyright 2024 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 filesystem + +import ( + "io/fs" + "os" +) + +// OSFileSystem is an implementation of the FileSystem interface that uses the OS filesystem. +// It uses the methods from the os package to interact with the filesystem. +type OSFileSystem struct{} + +var _ FileSystem = (*OSFileSystem)(nil) + +func NewOSFS() *OSFileSystem { + return &OSFileSystem{} +} + +func (osfs OSFileSystem) Create(name string) (fs.File, error) { + return os.Create(name) +} + +func (osfs OSFileSystem) Exists(name string) bool { + _, err := os.Stat(name) + return err != nil +} + +func (osfs OSFileSystem) Open(name string) (fs.File, error) { + return os.Open(name) +} + +func (osfs OSFileSystem) ReadFile(name string) ([]byte, error) { + return os.ReadFile(name) +} + +func (osfs OSFileSystem) Stat(name string) (fs.FileInfo, error) { + return os.Stat(name) +} + +func (osfs OSFileSystem) WriteFile(name string, data []byte, perm fs.FileMode) error { + return os.WriteFile(name, data, perm) +} diff --git a/pkg/controller/api/radapp.io/v1alpha3/deploymentresource_types.go b/pkg/controller/api/radapp.io/v1alpha3/deploymentresource_types.go new file mode 100644 index 0000000000..ddc8cff33c --- /dev/null +++ b/pkg/controller/api/radapp.io/v1alpha3/deploymentresource_types.go @@ -0,0 +1,86 @@ +/* +Copyright 2024 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 v1alpha3 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DeploymentResourceSpec defines the desired state of a DeploymentResource resource. +type DeploymentResourceSpec struct { + // Id is the resource id of the Radius resource. + Id string `json:"id,omitempty"` +} + +// DeploymentResourceStatus defines the observed state of a DeploymentResource resource. +type DeploymentResourceStatus struct { + // Id is the resource id of the Radius resource. + Id string `json:"id,omitempty"` + + // ObservedGeneration is the most recent generation observed for this DeploymentResource. + ObservedGeneration int64 `json:"observedGeneration,omitempty" protobuf:"varint,1,opt,name=observedGeneration"` + + // Operation tracks the status of an in-progress provisioning operation. + Operation *ResourceOperation `json:"operation,omitempty"` + + // Phrase indicates the current status of the Deployment Resource. + Phrase DeploymentResourcePhrase `json:"phrase,omitempty"` +} + +// DeploymentResourcePhrase is a string representation of the current status of a Deployment Resource. +type DeploymentResourcePhrase string + +const ( + // DeploymentResourcePhraseReady indicates that the Deployment Resource is ready. + DeploymentResourcePhraseReady DeploymentResourcePhrase = "Ready" + + // DeploymentResourcePhraseFailed indicates that the Deployment Resource has failed. + DeploymentResourcePhraseFailed DeploymentResourcePhrase = "Failed" + + // DeploymentResourcePhraseDeleting indicates that the Deployment Resource is being deleted. + DeploymentResourcePhraseDeleting DeploymentResourcePhrase = "Deleting" + + // DeploymentResourcePhraseDeleted indicates that the Deployment Resource has been deleted. + DeploymentResourcePhraseDeleted DeploymentResourcePhrase = "Deleted" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phrase",description="Status of the resource" +// +kubebuilder:resource:categories={"all","radius"} + +// DeploymentResource is the Schema for the DeploymentResources API +type DeploymentResource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DeploymentResourceSpec `json:"spec,omitempty"` + Status DeploymentResourceStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// DeploymentResourceList contains a list of DeploymentResource +type DeploymentResourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DeploymentResource `json:"items"` +} + +func init() { + SchemeBuilder.Register(&DeploymentResource{}, &DeploymentResourceList{}) +} diff --git a/pkg/controller/api/radapp.io/v1alpha3/deploymenttemplate_types.go b/pkg/controller/api/radapp.io/v1alpha3/deploymenttemplate_types.go new file mode 100644 index 0000000000..8e97c9d186 --- /dev/null +++ b/pkg/controller/api/radapp.io/v1alpha3/deploymenttemplate_types.go @@ -0,0 +1,98 @@ +/* +Copyright 2024 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 v1alpha3 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DeploymentTemplateSpec defines the desired state of a DeploymentTemplate resource. +type DeploymentTemplateSpec struct { + // Template is the ARM JSON manifest that defines the resources to deploy. + Template string `json:"template,omitempty"` + + // Parameters is the ARM JSON parameters for the template. + Parameters map[string]string `json:"parameters,omitempty"` + + // ProviderConfig specifies the scopes for resources. + ProviderConfig string `json:"providerConfig,omitempty"` +} + +// DeploymentTemplateStatus defines the observed state of a DeploymentTemplate resource. +type DeploymentTemplateStatus struct { + // ObservedGeneration is the most recent generation observed for this DeploymentTemplate. + ObservedGeneration int64 `json:"observedGeneration,omitempty" protobuf:"varint,1,opt,name=observedGeneration"` + + // StatusHash is a hash of the DeploymentTemplate's state (template, parameters, and provider config). + StatusHash string `json:"statusHash,omitempty"` + + // OutputResources is a list of the resourceIDs that were created by the template on the last deployment. + OutputResources []string `json:"outputResources,omitempty"` + + // Operation tracks the status of an in-progress provisioning operation. + Operation *ResourceOperation `json:"operation,omitempty"` + + // Phrase indicates the current status of the Deployment Template. + Phrase DeploymentTemplatePhrase `json:"phrase,omitempty"` +} + +// DeploymentTemplatePhrase is a string representation of the current status of a Deployment Template. +type DeploymentTemplatePhrase string + +const ( + // DeploymentTemplatePhraseUpdating indicates that the Deployment Template is being updated. + DeploymentTemplatePhraseUpdating DeploymentTemplatePhrase = "Updating" + + // DeploymentTemplatePhraseReady indicates that the Deployment Template is ready. + DeploymentTemplatePhraseReady DeploymentTemplatePhrase = "Ready" + + // DeploymentTemplatePhraseFailed indicates that the Deployment Template has failed. + DeploymentTemplatePhraseFailed DeploymentTemplatePhrase = "Failed" + + // DeploymentTemplatePhraseDeleting indicates that the Deployment Template is being deleted. + DeploymentTemplatePhraseDeleting DeploymentTemplatePhrase = "Deleting" + + // DeploymentTemplatePhraseDeleted indicates that the Deployment Template has been deleted. + DeploymentTemplatePhraseDeleted DeploymentTemplatePhrase = "Deleted" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phrase",description="Status of the resource" +// +kubebuilder:resource:categories={"all","radius"} + +// DeploymentTemplate is the Schema for the deploymenttemplates API +type DeploymentTemplate struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DeploymentTemplateSpec `json:"spec,omitempty"` + Status DeploymentTemplateStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// DeploymentTemplateList contains a list of DeploymentTemplate +type DeploymentTemplateList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DeploymentTemplate `json:"items"` +} + +func init() { + SchemeBuilder.Register(&DeploymentTemplate{}, &DeploymentTemplateList{}) +} diff --git a/pkg/controller/api/radapp.io/v1alpha3/zz_generated.deepcopy.go b/pkg/controller/api/radapp.io/v1alpha3/zz_generated.deepcopy.go index 90116af1dc..986e7ef2d0 100644 --- a/pkg/controller/api/radapp.io/v1alpha3/zz_generated.deepcopy.go +++ b/pkg/controller/api/radapp.io/v1alpha3/zz_generated.deepcopy.go @@ -24,6 +24,206 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentResource) DeepCopyInto(out *DeploymentResource) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentResource. +func (in *DeploymentResource) DeepCopy() *DeploymentResource { + if in == nil { + return nil + } + out := new(DeploymentResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DeploymentResource) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentResourceList) DeepCopyInto(out *DeploymentResourceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DeploymentResource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentResourceList. +func (in *DeploymentResourceList) DeepCopy() *DeploymentResourceList { + if in == nil { + return nil + } + out := new(DeploymentResourceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DeploymentResourceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentResourceSpec) DeepCopyInto(out *DeploymentResourceSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentResourceSpec. +func (in *DeploymentResourceSpec) DeepCopy() *DeploymentResourceSpec { + if in == nil { + return nil + } + out := new(DeploymentResourceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentResourceStatus) DeepCopyInto(out *DeploymentResourceStatus) { + *out = *in + if in.Operation != nil { + in, out := &in.Operation, &out.Operation + *out = new(ResourceOperation) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentResourceStatus. +func (in *DeploymentResourceStatus) DeepCopy() *DeploymentResourceStatus { + if in == nil { + return nil + } + out := new(DeploymentResourceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentTemplate) DeepCopyInto(out *DeploymentTemplate) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentTemplate. +func (in *DeploymentTemplate) DeepCopy() *DeploymentTemplate { + if in == nil { + return nil + } + out := new(DeploymentTemplate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DeploymentTemplate) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentTemplateList) DeepCopyInto(out *DeploymentTemplateList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DeploymentTemplate, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentTemplateList. +func (in *DeploymentTemplateList) DeepCopy() *DeploymentTemplateList { + if in == nil { + return nil + } + out := new(DeploymentTemplateList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DeploymentTemplateList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentTemplateSpec) DeepCopyInto(out *DeploymentTemplateSpec) { + *out = *in + if in.Parameters != nil { + in, out := &in.Parameters, &out.Parameters + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentTemplateSpec. +func (in *DeploymentTemplateSpec) DeepCopy() *DeploymentTemplateSpec { + if in == nil { + return nil + } + out := new(DeploymentTemplateSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentTemplateStatus) DeepCopyInto(out *DeploymentTemplateStatus) { + *out = *in + if in.OutputResources != nil { + in, out := &in.OutputResources, &out.OutputResources + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Operation != nil { + in, out := &in.Operation, &out.Operation + *out = new(ResourceOperation) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentTemplateStatus. +func (in *DeploymentTemplateStatus) DeepCopy() *DeploymentTemplateStatus { + if in == nil { + return nil + } + out := new(DeploymentTemplateStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Recipe) DeepCopyInto(out *Recipe) { *out = *in diff --git a/pkg/controller/reconciler/const.go b/pkg/controller/reconciler/const.go index b2f3739f13..77a39df103 100644 --- a/pkg/controller/reconciler/const.go +++ b/pkg/controller/reconciler/const.go @@ -47,4 +47,10 @@ const ( // RecipeFinalizer is the name of the finalizer added to Recipes. RecipeFinalizer = "radapp.io/recipe-finalizer" + + // DeploymentTemplateFinalizer is the name of the finalizer added to DeploymentTemplates. + DeploymentTemplateFinalizer = "radapp.io/deployment-template-finalizer" + + // DeploymentResourceFinalizer is the name of the finalizer added to DeploymentResources. + DeploymentResourceFinalizer = "radapp.io/deployment-resource-finalizer" ) diff --git a/pkg/controller/reconciler/deployment_reconciler.go b/pkg/controller/reconciler/deployment_reconciler.go index 9079570c3c..5d01a7864d 100644 --- a/pkg/controller/reconciler/deployment_reconciler.go +++ b/pkg/controller/reconciler/deployment_reconciler.go @@ -43,6 +43,7 @@ import ( radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/kubernetes" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/ucp/resources" "github.com/radius-project/radius/pkg/ucp/ucplog" @@ -403,7 +404,7 @@ func (r *DeploymentReconciler) reconcileDelete(ctx context.Context, deployment * return ctrl.Result{}, nil } -func (r *DeploymentReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Context, deployment *appsv1.Deployment, annotations *deploymentAnnotations) (Poller[v20231001preview.ContainersClientCreateOrUpdateResponse], Poller[v20231001preview.ContainersClientDeleteResponse], bool, error) { +func (r *DeploymentReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Context, deployment *appsv1.Deployment, annotations *deploymentAnnotations) (sdkclients.Poller[v20231001preview.ContainersClientCreateOrUpdateResponse], sdkclients.Poller[v20231001preview.ContainersClientDeleteResponse], bool, error) { logger := ucplog.FromContextOrDiscard(ctx) resourceID := annotations.Status.Scope + "/providers/Applications.Core/containers/" + deployment.Name @@ -479,7 +480,7 @@ func (r *DeploymentReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Con return poller, nil, false, nil } -func (r *DeploymentReconciler) startDeleteOperationIfNeeded(ctx context.Context, annotations *deploymentAnnotations) (Poller[v20231001preview.ContainersClientDeleteResponse], error) { +func (r *DeploymentReconciler) startDeleteOperationIfNeeded(ctx context.Context, annotations *deploymentAnnotations) (sdkclients.Poller[v20231001preview.ContainersClientDeleteResponse], error) { logger := ucplog.FromContextOrDiscard(ctx) if annotations.Status.Container == "" { logger.Info("Container is already deleted (or was never created).") diff --git a/pkg/controller/reconciler/deployment_reconciler_test.go b/pkg/controller/reconciler/deployment_reconciler_test.go index 68e6a597f6..49f02207e5 100644 --- a/pkg/controller/reconciler/deployment_reconciler_test.go +++ b/pkg/controller/reconciler/deployment_reconciler_test.go @@ -63,7 +63,7 @@ func SetupDeploymentTest(t *testing.T) (*mockRadiusClient, client.Client) { mgr, err := ctrl.NewManager(config, ctrl.Options{ Scheme: scheme, Controller: crconfig.Controller{ - SkipNameValidation: boolPtr(true), + SkipNameValidation: to.Ptr(true), }, // Suppress metrics in tests to avoid conflicts. @@ -108,7 +108,7 @@ func Test_DeploymentReconciler_RadiusEnabled_ThenDeploymentDeleted(t *testing.T) require.NoError(t, err) // Deployment will be waiting for environment to be created. - createEnvironment(radius, "default") + createEnvironment(radius, "default", "default") // Deployment will be waiting for container to complete deployment. annotations := waitForStateUpdating(t, client, name) @@ -154,7 +154,7 @@ func Test_DeploymentReconciler_ChangeEnvironmentAndApplication(t *testing.T) { require.NoError(t, err) // Deployment will be waiting for environment to be created. - createEnvironment(radius, "default") + createEnvironment(radius, "default", "default") // Deployment will be waiting for container to complete deployment. annotations := waitForStateUpdating(t, client, name) @@ -167,7 +167,7 @@ func Test_DeploymentReconciler_ChangeEnvironmentAndApplication(t *testing.T) { annotations = waitForStateReady(t, client, name) require.Equal(t, "/planes/radius/local/resourcegroups/default-deployment-change-envapp/providers/Applications.Core/containers/test-deployment-change-envapp", annotations.Status.Container) - createEnvironment(radius, "new-environment") + createEnvironment(radius, "new-environment", "new-environment") // Now update the deployment to change the environment and application. err = client.Get(ctx, name, deployment) @@ -228,7 +228,7 @@ func Test_DeploymentReconciler_RadiusEnabled_ThenRadiusDisabled(t *testing.T) { require.NoError(t, err) // Deployment will be waiting for environment to be created. - createEnvironment(radius, "default") + createEnvironment(radius, "default", "default") // Deployment will be waiting for container to complete deployment. annotations := waitForStateUpdating(t, client, name) @@ -283,7 +283,7 @@ func Test_DeploymentReconciler_Connections(t *testing.T) { require.NoError(t, err) // Deployment will be waiting for environment to be created. - createEnvironment(radius, "default") + createEnvironment(radius, "default", "default") // Deployment will be waiting for recipe resources to be created _ = waitForStateWaiting(t, client, name) diff --git a/pkg/controller/reconciler/deploymentresource_reconciler.go b/pkg/controller/reconciler/deploymentresource_reconciler.go new file mode 100644 index 0000000000..1c63d49ead --- /dev/null +++ b/pkg/controller/reconciler/deploymentresource_reconciler.go @@ -0,0 +1,390 @@ +/* +Copyright 2024 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 reconciler + +import ( + "context" + "fmt" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/go-logr/logr" + "github.com/radius-project/radius/pkg/cli/clients" + radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" + "github.com/radius-project/radius/pkg/ucp/resources" + "github.com/radius-project/radius/pkg/ucp/ucplog" + corev1 "k8s.io/api/core/v1" +) + +// DeploymentResourceReconciler reconciles a DeploymentResource object. +type DeploymentResourceReconciler struct { + // Client is the Kubernetes client. + Client client.Client + + // Scheme is the Kubernetes scheme. + Scheme *runtime.Scheme + + // EventRecorder is the Kubernetes event recorder. + EventRecorder record.EventRecorder + + // Radius is the Radius client. + Radius RadiusClient + + // ResourceDeploymentsClient is the client for managing deployments. + ResourceDeploymentsClient sdkclients.ResourceDeploymentsClient + + // DelayInterval is the amount of time to wait between operations. + DelayInterval time.Duration +} + +// Reconcile is the main reconciliation loop for the DeploymentResource resource. +func (r *DeploymentResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := ucplog.FromContextOrDiscard(ctx).WithValues("kind", "DeploymentResource", "name", req.Name, "namespace", req.Namespace) + ctx = logr.NewContext(ctx, logger) + + deploymentResource := radappiov1alpha3.DeploymentResource{} + err := r.Client.Get(ctx, req.NamespacedName, &deploymentResource) + if apierrors.IsNotFound(err) { + // This can happen due to a data-race if the Deployment Resource is created and then deleted before we can + // reconcile it. There's nothing to do here. + logger.Info("DeploymentResource is being deleted.") + return ctrl.Result{}, nil + } else if err != nil { + logger.Error(err, "Unable to fetch resource.") + return ctrl.Result{}, err + } + + // Our algorithm is as follows: + // + // 1. Check if there is an in-progress deletion. If so, check its status: + // 1. If the deletion is still in progress, then queue another reconcile operation and continue processing. + // 2. If the deletion completed successfully, then remove the `radapp.io/deployment-resource-finalizer` finalizer from the resource and continue processing. + // 3. If the operation failed, then update the `status.phrase` and `status.message` as `Failed`. + // 2. If the `DeploymentTemplate` is being deleted, then process deletion: + // 1. Send a DELETE operation to the Radius API to delete the resource specified in the `spec.resourceId` field. + // 2. Continue processing. + // 3. If the `DeploymentTemplate` is not being deleted then process this as a create or update: + // 1. Set the `status.phrase` for the `DeploymentResource` to `Ready`. + // 2. Continue processing. + // + // We do it this way because it guarantees that we only have one operation going at a time. + + if deploymentResource.Status.Operation != nil { + result, err := r.reconcileOperation(ctx, &deploymentResource) + if err != nil { + logger.Error(err, "Unable to reconcile in-progress operation.") + return ctrl.Result{}, err + } else if result.IsZero() { + // NOTE: if reconcileOperation completes successfully, then it will return a "zero" result, + // this means the operation has completed and we should continue processing. + logger.Info("Operation completed successfully.") + } else { + logger.Info("Requeueing to continue operation.") + return result, nil + } + } + + if deploymentResource.DeletionTimestamp != nil { + return r.reconcileDelete(ctx, &deploymentResource) + } + + logger.Info("Resource is in desired state.") + + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseReady + deploymentResource.Status.Id = deploymentResource.Spec.Id + err = r.Client.Status().Update(ctx, &deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + + r.EventRecorder.Event(&deploymentResource, corev1.EventTypeNormal, "Reconciled", "Successfully reconciled resource.") + return ctrl.Result{}, nil +} + +// reconcileOperation reconciles a DeploymentResource that has an operation in progress. +func (r *DeploymentResourceReconciler) reconcileOperation(ctx context.Context, deploymentResource *radappiov1alpha3.DeploymentResource) (ctrl.Result, error) { + logger := ucplog.FromContextOrDiscard(ctx) + + if deploymentResource.Status.Operation.OperationKind == radappiov1alpha3.OperationKindDelete { + + poller, err := r.ResourceDeploymentsClient.ContinueDeleteOperation(ctx, deploymentResource.Status.Operation.ResumeToken) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to continue DELETE operation: %w", err) + } + + _, err = poller.Poll(ctx) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to poll operation status: %w", err) + } + + if !poller.Done() { + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } + + // If we get here, the operation is complete. + _, err = poller.Result(ctx) + if err != nil { + if clients.Is404Error(err) { + // The resource was not found, so we can consider it deleted. + logger.Info("Resource was not found.") + + // At this point we've cleaned up everything. We can remove the finalizer which will allow deletion of the + // DeploymentResource + if controllerutil.RemoveFinalizer(deploymentResource, DeploymentResourceFinalizer) { + deploymentResource.Status.ObservedGeneration = deploymentResource.Generation + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseDeleted + err = r.Client.Update(ctx, deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + + // Operation failed, reset state and retry. + r.EventRecorder.Event(deploymentResource, corev1.EventTypeWarning, "ResourceError", err.Error()) + logger.Error(err, "Delete failed.") + + deploymentResource.Status.Operation = nil + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseFailed + err = r.Client.Status().Update(ctx, deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } + + // If we get here, the operation was a success. Update the status and continue. + deploymentResource.Status.Operation = nil + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseDeleted + err = r.Client.Status().Update(ctx, deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + + logger.Info("Resource is deleted.") + + // At this point we've cleaned up everything. We can remove the finalizer which will allow deletion of the + // DeploymentResource + if controllerutil.RemoveFinalizer(deploymentResource, DeploymentResourceFinalizer) { + deploymentResource.Status.ObservedGeneration = deploymentResource.Generation + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseDeleted + err = r.Client.Update(ctx, deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil + } + + // If we get here, this was an unknown operation kind. This is a bug in our code, or someone + // tampered with the status of the object. Just reset the state and move on. + logger.Error(fmt.Errorf("unknown operation kind: %s", deploymentResource.Status.Operation.OperationKind), "Unknown operation kind.") + + deploymentResource.Status.Operation = nil + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseFailed + err := r.Client.Status().Update(ctx, deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *DeploymentResourceReconciler) reconcileDelete(ctx context.Context, deploymentResource *radappiov1alpha3.DeploymentResource) (ctrl.Result, error) { + logger := ucplog.FromContextOrDiscard(ctx) + + logger.Info("Resource is being deleted.") + + // Since we're going to reconcile, update the observed generation. + // + // We don't want to do this if we're in the middle of an operation, because we haven't + // fully processed any status changes until the async operation completes. + deploymentResource.Status.ObservedGeneration = deploymentResource.Generation + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseDeleting + err := r.Client.Status().Update(ctx, deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + + // Check if the resource is being used by another resource + deploymentResourceList, err := listResourcesWithSameOwner(ctx, r.Client, deploymentResource.Namespace, deploymentResource.OwnerReferences[0]) + if err != nil { + return ctrl.Result{}, err + } + + // Check if the resource is being used by another resource + dependentResource, err := checkForDeploymentResourceDependencies(deploymentResource, deploymentResourceList) + if err != nil { + return ctrl.Result{}, err + } + + if dependentResource != "" { + logger.Info("Resource is an application or environment, being used by another resource.", "resourceId", deploymentResource.Spec.Id, "dependentResource", dependentResource) + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } + + deletePoller, err := r.startDeleteOperation(ctx, deploymentResource) + if err != nil { + logger.Error(err, "Unable to delete resource.") + r.EventRecorder.Event(deploymentResource, corev1.EventTypeWarning, "ResourceError", err.Error()) + return ctrl.Result{}, err + } else if deletePoller != nil && !deletePoller.Done() { + // We've successfully started an operation. Update the status and requeue. + token, err := deletePoller.ResumeToken() + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get operation token: %w", err) + } + + deploymentResource.Status.Operation = &radappiov1alpha3.ResourceOperation{ResumeToken: token, OperationKind: radappiov1alpha3.OperationKindDelete} + err = r.Client.Status().Update(ctx, deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } + + // If we get here then it means we can process the result of the operation. + logger.Info("Resource is deleted.") + + // At this point we've cleaned up everything. We can remove the finalizer which will allow deletion of the + // DeploymentResource + if controllerutil.RemoveFinalizer(deploymentResource, DeploymentResourceFinalizer) { + deploymentResource.Status.ObservedGeneration = deploymentResource.Generation + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseDeleted + err = r.Client.Update(ctx, deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + } + + // If we get here, then we're in a bad state. We should have removed the finalizer, but we didn't. + // We should requeue and try again. + + logger.Info("Finalizer was not removed, requeueing.") + + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil +} + +func (r *DeploymentResourceReconciler) startDeleteOperation(ctx context.Context, deploymentResource *radappiov1alpha3.DeploymentResource) (sdkclients.Poller[sdkclients.ClientDeleteResponse], error) { + logger := ucplog.FromContextOrDiscard(ctx) + + resourceId := deploymentResource.Spec.Id + radiusAPIVersion := "2023-10-01-preview" + + logger.Info("Starting DELETE operation.") + poller, err := r.ResourceDeploymentsClient.Delete(ctx, resourceId, radiusAPIVersion) + if err != nil { + return nil, err + } else if poller != nil { + return poller, nil + } + + // Deletion was synchronous + return nil, nil +} + +func (r *DeploymentResourceReconciler) requeueDelay() time.Duration { + delay := r.DelayInterval + if delay == 0 { + delay = PollingDelay + } + + return delay +} + +// SetupWithManager sets up the controller with the Manager. +func (r *DeploymentResourceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&radappiov1alpha3.DeploymentResource{}). + Complete(r) +} + +func listResourcesWithSameOwner(ctx context.Context, c client.Client, namespace string, ownerRef metav1.OwnerReference) ([]radappiov1alpha3.DeploymentResource, error) { + // List all DeploymentResource objects in the same namespace + deploymentResourceList := &radappiov1alpha3.DeploymentResourceList{} + err := c.List(ctx, deploymentResourceList, client.InNamespace(namespace)) + if err != nil { + return nil, err + } + + // Filter resources based on OwnerReference + var filteredResources []radappiov1alpha3.DeploymentResource + for _, dr := range deploymentResourceList.Items { + for _, or := range dr.OwnerReferences { + if or.UID == ownerRef.UID { + filteredResources = append(filteredResources, dr) + break + } + } + } + + return filteredResources, nil +} + +// checkForDeploymentResourceDependencies checks if the deploymentResource is an application or environment. +// If it is, it checks if other (non-application or environment) resources exist. +// If other resources exist, it returns the ID of one of the dependent resources. +// NOTE: This is a workaround for existing Radius API behavior. Since deleting +// an application or environment can leave hanging resources, we need to make sure to +// delete these resources before deleting the application or environment. +// https://github.com/radius-project/radius/issues/8164 +func checkForDeploymentResourceDependencies(deploymentResource *radappiov1alpha3.DeploymentResource, deploymentResourceList []radappiov1alpha3.DeploymentResource) (string, error) { + deploymentResourceID, err := resources.ParseResource(deploymentResource.Spec.Id) + if err != nil { + return "", err + } + + // If the deploymentResource is an application or environment, check if other resources exist + if strings.EqualFold(deploymentResourceID.Type(), "Applications.Core/applications") || strings.EqualFold(deploymentResourceID.Type(), "Applications.Core/environments") { + resourceCount := 0 + dependentResource := "" + for _, dr := range deploymentResourceList { + if dr.Status.Phrase == radappiov1alpha3.DeploymentResourcePhraseDeleted { + continue + } + + id, err := resources.ParseResource(dr.Spec.Id) + if err != nil { + return "", err + } + + // don't count applications or environments + if !strings.EqualFold(id.Type(), "Applications.Core/applications") && !strings.EqualFold(id.Type(), "Applications.Core/environments") { + resourceCount++ + dependentResource = dr.Spec.Id + } + } + + return dependentResource, nil + } + + // If the deploymentResource is not an application or environment, just return + return "", nil +} diff --git a/pkg/controller/reconciler/deploymentresource_reconciler_test.go b/pkg/controller/reconciler/deploymentresource_reconciler_test.go new file mode 100644 index 0000000000..3131a76b19 --- /dev/null +++ b/pkg/controller/reconciler/deploymentresource_reconciler_test.go @@ -0,0 +1,186 @@ +/* +Copyright 2024 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 reconciler + +import ( + "fmt" + "testing" + "time" + + radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/test/testcontext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + k8sClient "sigs.k8s.io/controller-runtime/pkg/client" + crconfig "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +const ( + DeploymentResourceTestWaitDuration = time.Second * 10 + DeploymentResourceTestWaitInterval = time.Second * 1 + DeploymentResourceTestControllerDelayInterval = time.Millisecond * 100 + + TestDeploymentResourceNamespace = "deploymentresource-basic" + TestDeploymentResourceName = "test-deploymentresource" + TestDeploymentResourceRadiusResourceGroup = "default-deploymentresource-basic" +) + +var ( + TestDeploymentResourceScope = fmt.Sprintf("/planes/radius/local/resourcegroups/%s", TestDeploymentResourceRadiusResourceGroup) + TestDeploymentResourceID = fmt.Sprintf("%s/providers/Microsoft.Resources/deployments/%s", TestDeploymentResourceScope, TestDeploymentResourceName) +) + +func SetupDeploymentResourceTest(t *testing.T) (*mockRadiusClient, *sdkclients.MockResourceDeploymentsClient, k8sClient.Client) { + SkipWithoutEnvironment(t) + + // For debugging, you can set uncomment this to see logs from the controller. This will cause tests to fail + // because the logging will continue after the test completes. + // + // Add runtimelog "sigs.k8s.io/controller-runtime/pkg/log" to imports. + // + // runtimelog.SetLogger(ucplog.FromContextOrDiscard(testcontext.New(t))) + + // Shut down the manager when the test exits. + ctx, cancel := testcontext.NewWithCancel(t) + t.Cleanup(cancel) + + mgr, err := ctrl.NewManager(config, ctrl.Options{ + Scheme: scheme, + Controller: crconfig.Controller{ + SkipNameValidation: to.Ptr(true), + }, + + // Suppress metrics in tests to avoid conflicts. + Metrics: server.Options{ + BindAddress: "0", + }, + }) + require.NoError(t, err) + + mockRadiusClient := NewMockRadiusClient() + mockResourceDeploymentsClient := sdkclients.NewMockResourceDeploymentsClient() + + err = (&DeploymentResourceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventRecorder: mgr.GetEventRecorderFor("deploymentresource-controller"), + Radius: mockRadiusClient, + ResourceDeploymentsClient: mockResourceDeploymentsClient, + DelayInterval: DeploymentResourceTestControllerDelayInterval, + }).SetupWithManager(mgr) + require.NoError(t, err) + + go func() { + err := mgr.Start(ctx) + require.NoError(t, err) + }() + + return mockRadiusClient, mockResourceDeploymentsClient, mgr.GetClient() +} + +func Test_DeploymentResourceReconciler_Basic(t *testing.T) { + ctx := testcontext.New(t) + _, _, k8sClient := SetupDeploymentTemplateTest(t) + + name := types.NamespacedName{Namespace: TestDeploymentResourceNamespace, Name: TestDeploymentResourceName} + err := k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: name.Namespace}}) + require.NoError(t, err) + + deployment := makeDeploymentResource(name, TestDeploymentResourceID) + err = k8sClient.Create(ctx, deployment) + require.NoError(t, err) + + // Deployment will update after operation completes + status := waitForDeploymentResourceStateReady(t, k8sClient, name) + require.Equal(t, TestDeploymentResourceID, status.Id) + + err = k8sClient.Delete(ctx, deployment) + require.NoError(t, err) + + // Now deleting of the DeploymentResource object can complete. + waitForDeploymentResourceDeleted(t, k8sClient, name) +} + +func waitForDeploymentResourceStateReady(t *testing.T, client k8sClient.Client, name types.NamespacedName) *radappiov1alpha3.DeploymentResourceStatus { + ctx := testcontext.New(t) + + logger := t + status := &radappiov1alpha3.DeploymentResourceStatus{} + require.EventuallyWithTf(t, func(t *assert.CollectT) { + logger.Logf("Fetching DeploymentResource: %+v", name) + current := &radappiov1alpha3.DeploymentResource{} + err := client.Get(ctx, name, current) + require.NoError(t, err) + + status = ¤t.Status + logger.Logf("DeploymentResource.Status: %+v", current.Status) + if assert.Equal(t, radappiov1alpha3.DeploymentResourcePhraseReady, current.Status.Phrase) { + assert.Empty(t, current.Status.Operation) + } + }, DeploymentResourceTestWaitDuration, DeploymentResourceTestWaitInterval, "failed to enter ready state") + + return status +} + +func waitForDeploymentResourceStateDeleting(t *testing.T, client k8sClient.Client, name types.NamespacedName, oldOperation *radappiov1alpha3.ResourceOperation) *radappiov1alpha3.DeploymentResourceStatus { + ctx := testcontext.New(t) + + logger := t + status := &radappiov1alpha3.DeploymentResourceStatus{} + require.EventuallyWithTf(t, func(t *assert.CollectT) { + logger.Logf("Fetching DeploymentResource: %+v", name) + current := &radappiov1alpha3.DeploymentResource{} + err := client.Get(ctx, name, current) + assert.NoError(t, err) + + status = ¤t.Status + logger.Logf("DeploymentResource.Status: %+v", current.Status) + assert.Equal(t, status.ObservedGeneration, current.Generation, "Status is not updated") + + if assert.Equal(t, radappiov1alpha3.DeploymentResourcePhraseDeleting, current.Status.Phrase) { + assert.NotEmpty(t, current.Status.Operation) + assert.NotEqual(t, oldOperation, current.Status.Operation) + } + }, DeploymentResourceTestWaitDuration, DeploymentResourceTestWaitInterval, "failed to enter deleting state") + + return status +} + +func waitForDeploymentResourceDeleted(t *testing.T, client k8sClient.Client, name types.NamespacedName) { + ctx := testcontext.New(t) + + logger := t + require.Eventuallyf(t, func() bool { + logger.Logf("Fetching DeploymentResource: %+v", name) + current := &radappiov1alpha3.DeploymentResource{} + err := client.Get(ctx, name, current) + if apierrors.IsNotFound(err) { + return true + } + + logger.Logf("DeploymentResource.Status: %+v", current.Status) + return false + + }, DeploymentResourceTestWaitDuration, DeploymentResourceTestWaitInterval, "DeploymentResource still exists") +} diff --git a/pkg/controller/reconciler/deploymenttemplate_reconciler.go b/pkg/controller/reconciler/deploymenttemplate_reconciler.go new file mode 100644 index 0000000000..b0c04e7cc9 --- /dev/null +++ b/pkg/controller/reconciler/deploymenttemplate_reconciler.go @@ -0,0 +1,551 @@ +/* +Copyright 2024 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 reconciler + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/go-logr/logr" + "github.com/google/uuid" + radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" + "github.com/radius-project/radius/pkg/ucp/ucplog" + corev1 "k8s.io/api/core/v1" +) + +// DeploymentTemplateReconciler reconciles a DeploymentTemplate object. +type DeploymentTemplateReconciler struct { + // Client is the Kubernetes client. + Client client.Client + + // Scheme is the Kubernetes scheme. + Scheme *k8sruntime.Scheme + + // EventRecorder is the Kubernetes event recorder. + EventRecorder record.EventRecorder + + // Radius is the Radius client. + Radius RadiusClient + + // ResourceDeploymentsClient is the client for managing deployments. + ResourceDeploymentsClient sdkclients.ResourceDeploymentsClient + + // DelayInterval is the amount of time to wait between operations. + DelayInterval time.Duration +} + +// Reconcile is the main reconciliation loop for the DeploymentTemplate resource. +func (r *DeploymentTemplateReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := ucplog.FromContextOrDiscard(ctx).WithValues("kind", "DeploymentTemplate", "name", req.Name, "namespace", req.Namespace) + ctx = logr.NewContext(ctx, logger) + + deploymentTemplate := radappiov1alpha3.DeploymentTemplate{} + err := r.Client.Get(ctx, req.NamespacedName, &deploymentTemplate) + if apierrors.IsNotFound(err) { + // This can happen due to a data-race if the Deployment Template is created and then deleted before we can + // reconcile it. There's nothing to do here. + logger.Info("DeploymentTemplate is being deleted.") + return ctrl.Result{}, nil + } else if err != nil { + logger.Error(err, "Unable to fetch resource.") + return ctrl.Result{}, err + } + + // Our algorithm is as follows: + // + // 1. Check if there is an in-progress operation. If so, check its status: + // 1. If the operation is still in progress, then queue another reconcile operation and continue processing. + // 2. If the operation completed successfully: + // 1. Diff the resources in the `properties.outputResources` field returned by the Radius API with the resources in the `status.outputResources` field on the `DeploymentTemplate` resource. + // 2. Depending on the diff, create or delete `DeploymentResource` resources on the cluster. In the case of create, add the `DeploymentTemplate` as the owner of the `DeploymentResource` and set the `radapp.io/deployment-resource-finalizer` finalizer on the `DeploymentResource`. + // 3. Update the `status.phrase` for the `DeploymentTemplate` to `Ready`. + // 4. Continue processing. + // 3. If the operation failed, then update the `status.phrase` as `Failed` and continue processing. + // 2. If the `DeploymentTemplate` is being deleted, then process deletion: + // 1. Since the `DeploymentResources` are owned by the `DeploymentTemplate`, the `DeploymentResource` resources will be deleted first. Once they are deleted, the `DeploymentTemplate` resource will be deleted. + // 2. Once the dependent resources are deleted, remove the `radapp.io/deployment-template-finalizer` finalizer from the `DeploymentTemplate`. + // 3. If the `DeploymentTemplate` is not being deleted then process this as a create or update: + // 1. Add the `radapp.io/deployment-template-finalizer` finalizer onto the `DeploymentTemplate` resource. + // 2. Check if the desired state of the `DeploymentTemplate` resource matches the observed state. If it does, then the resource is up-to-date and we can continue processing. + // 3. Otherwise, queue a PUT operation against the Radius API to deploy the ARM JSON in the `spec.template` field with the parameters in the `spec.parameters` field. + // 4. Set the `status.phrase` for the `DeploymentTemplate` to `Updating` and the `status.operation` to the operation returned by the Radius API. + // 5. Continue processing. + // + // We do it this way because it guarantees that we only have one operation going at a time. + + if deploymentTemplate.Status.Operation != nil { + result, err := r.reconcileOperation(ctx, &deploymentTemplate) + if err != nil { + logger.Error(err, "Unable to reconcile in-progress operation.") + return ctrl.Result{}, err + } else if result.IsZero() { + // If reconcileOperation completes successfully, then it will return a "zero" result, + // this means the operation has completed and we should continue processing. + logger.Info("Operation completed successfully.") + } else { + logger.Info("Requeueing to continue operation.") + return result, nil + } + } + + if deploymentTemplate.DeletionTimestamp != nil { + return r.reconcileDelete(ctx, &deploymentTemplate) + } + + return r.reconcileUpdate(ctx, &deploymentTemplate) +} + +// reconcileOperation reconciles a DeploymentTemplate that has an operation in progress. +func (r *DeploymentTemplateReconciler) reconcileOperation(ctx context.Context, deploymentTemplate *radappiov1alpha3.DeploymentTemplate) (ctrl.Result, error) { + logger := ucplog.FromContextOrDiscard(ctx) + + if deploymentTemplate.Status.Operation.OperationKind == radappiov1alpha3.OperationKindPut { + poller, err := r.ResourceDeploymentsClient.ContinueCreateOperation(ctx, deploymentTemplate.Status.Operation.ResumeToken) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to continue PUT operation: %w", err) + } + + _, err = poller.Poll(ctx) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to poll operation status: %w", err) + } + + if !poller.Done() { + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } + + // If we get here, the operation is complete. + resp, err := poller.Result(ctx) + if err != nil { + // Operation failed, reset state and retry. + r.EventRecorder.Event(deploymentTemplate, corev1.EventTypeWarning, "ResourceError", err.Error()) + logger.Error(err, "Update failed.") + + deploymentTemplate.Status.Operation = nil + deploymentTemplate.Status.Phrase = radappiov1alpha3.DeploymentTemplatePhraseFailed + err = r.Client.Status().Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } + + logger.Info("Creating output resources.") + + // Get outputResources from the response + outputResources := make([]string, 0) + if resp.Properties != nil && resp.Properties.OutputResources != nil { + for _, resource := range resp.Properties.OutputResources { + if resource.ID != nil { + outputResources = append(outputResources, *resource.ID) + } + } + + // Compare outputResources with existing DeploymentResources + // if is present in deploymentTemplate.Status.OutputResources but not in outputResources, delete it + // if is not present in deploymentTemplate.Status.OutputResources but is in outputResources, create it + // if is present in both, do nothing + + existingOutputResources := make(map[string]bool) + for _, resource := range deploymentTemplate.Status.OutputResources { + existingOutputResources[resource] = true + } + + newOutputResources := make(map[string]bool) + for _, resource := range outputResources { + newOutputResources[resource] = true + } + + for _, outputResourceId := range outputResources { + if _, ok := existingOutputResources[outputResourceId]; !ok { + // Resource is not present in deploymentTemplate.Status.OutputResources but is in outputResources, create it + + logger.Info("Creating DeploymentResource.", "resourceId", outputResourceId) + resourceName, err := generateDeploymentResourceName(outputResourceId) + if err != nil { + return ctrl.Result{}, err + } + + deploymentResource := &radappiov1alpha3.DeploymentResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: deploymentTemplate.Namespace, + }, + Spec: radappiov1alpha3.DeploymentResourceSpec{ + Id: outputResourceId, + }, + } + + if controllerutil.AddFinalizer(deploymentResource, DeploymentResourceFinalizer) { + // Add the DeploymentTemplate as the owner of the DeploymentResource + if err := controllerutil.SetControllerReference(deploymentTemplate, deploymentResource, r.Scheme); err != nil { + return ctrl.Result{}, err + } + + // Create the DeploymentResource + err = r.Client.Create(ctx, deploymentResource) + if err != nil { + return ctrl.Result{}, err + } + } + } + } + + for _, resource := range deploymentTemplate.Status.OutputResources { + if _, ok := newOutputResources[resource]; !ok { + // Resource is present in deploymentTemplate.Status.OutputResources but not in outputResources, delete it + + logger.Info("Deleting resource.", "resourceId", resource) + resourceName, err := generateDeploymentResourceName(resource) + if err != nil { + return ctrl.Result{}, err + } + + err = r.Client.Delete(ctx, &radappiov1alpha3.DeploymentResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: deploymentTemplate.Namespace, + }, + }) + if err != nil { + return ctrl.Result{}, err + } + } + } + } + + hash, err := computeHash(deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + // If we get here, the operation was a success. Update the status and continue. + deploymentTemplate.Status.Operation = nil + deploymentTemplate.Status.OutputResources = outputResources + deploymentTemplate.Status.StatusHash = hash + err = r.Client.Status().Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil + } + + // If we get here, this was an unknown operation kind. This is a bug in our code, or someone + // tampered with the status of the object. Just reset the state and move on. + errorMessage := fmt.Errorf("unknown operation kind: %s", deploymentTemplate.Status.Operation.OperationKind) + logger.Error(errorMessage, "Unknown operation kind.") + + deploymentTemplate.Status.Operation = nil + deploymentTemplate.Status.Phrase = radappiov1alpha3.DeploymentTemplatePhraseFailed + err := r.Client.Status().Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *DeploymentTemplateReconciler) reconcileUpdate(ctx context.Context, deploymentTemplate *radappiov1alpha3.DeploymentTemplate) (ctrl.Result, error) { + logger := ucplog.FromContextOrDiscard(ctx) + + logger.Info("Reconciling resource.") + + // Ensure that our finalizer is present before we start any operations. + if controllerutil.AddFinalizer(deploymentTemplate, DeploymentTemplateFinalizer) { + err := r.Client.Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + } + + // Since we're going to reconcile, update the observed generation. + // + // We don't want to do this if we're in the middle of an operation, because we haven't + // fully processed any status changes until the async operation completes. + deploymentTemplate.Status.ObservedGeneration = deploymentTemplate.Generation + + updatePoller, err := r.startPutOperationIfNeeded(ctx, deploymentTemplate) + if err != nil { + logger.Error(err, "Unable to create or update resource.") + r.EventRecorder.Event(deploymentTemplate, corev1.EventTypeWarning, "ResourceError", err.Error()) + deploymentTemplate.Status.Phrase = radappiov1alpha3.DeploymentTemplatePhraseFailed + err = r.Client.Status().Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, err + } else if updatePoller != nil { + // We've successfully started an operation. Update the status and requeue. + token, err := updatePoller.ResumeToken() + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get operation token: %w", err) + } + + deploymentTemplate.Status.Operation = &radappiov1alpha3.ResourceOperation{ResumeToken: token, OperationKind: radappiov1alpha3.OperationKindPut} + deploymentTemplate.Status.Phrase = radappiov1alpha3.DeploymentTemplatePhraseUpdating + err = r.Client.Status().Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } + + // If we get here then it means we can process the result of the operation. + logger.Info("Resource is in desired state.") + + deploymentTemplate.Status.Phrase = radappiov1alpha3.DeploymentTemplatePhraseReady + err = r.Client.Status().Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + r.EventRecorder.Event(deploymentTemplate, corev1.EventTypeNormal, "Reconciled", "Successfully reconciled resource.") + return ctrl.Result{}, nil +} + +func (r *DeploymentTemplateReconciler) reconcileDelete(ctx context.Context, deploymentTemplate *radappiov1alpha3.DeploymentTemplate) (ctrl.Result, error) { + logger := ucplog.FromContextOrDiscard(ctx) + + logger.Info("Resource is being deleted.") + + // Since we're going to reconcile, update the observed generation. + // + // We don't want to do this if we're in the middle of an operation, because we haven't + // fully processed any status changes until the async operation completes. + deploymentTemplate.Status.ObservedGeneration = deploymentTemplate.Generation + deploymentTemplate.Status.Phrase = radappiov1alpha3.DeploymentTemplatePhraseDeleting + err := r.Client.Status().Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + // List all DeploymentResource objects in the same namespace + deploymentResourceList := &radappiov1alpha3.DeploymentResourceList{} + err = r.Client.List(ctx, deploymentResourceList, client.InNamespace(deploymentTemplate.Namespace)) + if err != nil { + return ctrl.Result{}, nil + } + + // Filter the list to include only those owned by the current DeploymentTemplate + var ownedResources []radappiov1alpha3.DeploymentResource + for _, resource := range deploymentResourceList.Items { + if isOwnedBy(resource, deploymentTemplate) { + ownedResources = append(ownedResources, resource) + } + } + + // If there are still owned DeploymentResources, we need to trigger deletion and wait for them + // to be deleted before we can delete the DeploymentTemplate. + if len(ownedResources) > 0 { + logger.Info("Owned resources still exist, waiting for deletion.") + + // Trigger deletion of owned resources + for _, resource := range ownedResources { + err := r.Client.Delete(ctx, &resource) + if err != nil { + return ctrl.Result{}, err + } + } + + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil + } + + logger.Info("Resource is deleted.") + + // At this point we've cleaned up everything. We can remove the finalizer which will allow + // deletion of the DeploymentTemplate. + if controllerutil.RemoveFinalizer(deploymentTemplate, DeploymentTemplateFinalizer) { + deploymentTemplate.Status.ObservedGeneration = deploymentTemplate.Generation + deploymentTemplate.Status.Phrase = radappiov1alpha3.DeploymentTemplatePhraseDeleted + err = r.Client.Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + r.EventRecorder.Event(deploymentTemplate, corev1.EventTypeNormal, "Reconciled", "Successfully reconciled resource.") + return ctrl.Result{}, nil + } + + logger.Info("Finalizer was not removed, requeueing.") + + err = r.Client.Status().Update(ctx, deploymentTemplate) + if err != nil { + return ctrl.Result{}, err + } + + // If we get here, then we're in a bad state. We should have removed the finalizer, but we didn't. + // We should requeue and try again. + + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil +} + +func (r *DeploymentTemplateReconciler) startPutOperationIfNeeded(ctx context.Context, deploymentTemplate *radappiov1alpha3.DeploymentTemplate) (sdkclients.Poller[sdkclients.ClientCreateOrUpdateResponse], error) { + logger := ucplog.FromContextOrDiscard(ctx) + + specParameters := convertToARMJSONParameters(deploymentTemplate.Spec.Parameters) + + // If the resource is already created and is up-to-date, then we don't need to do anything. + if isUpToDate(deploymentTemplate) { + logger.Info("Resource is up-to-date.") + return nil, nil + } + + logger.Info("Desired state has changed, starting PUT operation.") + + var template any + err := json.Unmarshal([]byte(deploymentTemplate.Spec.Template), &template) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal template: %w", err) + } + + providerConfig := sdkclients.ProviderConfig{} + err = json.Unmarshal([]byte(deploymentTemplate.Spec.ProviderConfig), &providerConfig) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal providerConfig: %w", err) + } + if providerConfig.Deployments == nil { + return nil, fmt.Errorf("providerConfig.Deployments is nil") + } + if providerConfig.Deployments.Value.Scope == "" { + return nil, fmt.Errorf("providerConfig.Deployments.Value.Scope is empty") + } + + // Create the Radius resource group corresponding the providerConfig.Deployments.Value.Scope + // if it does not exist. This is necessary because the resource group is required for the + // deployment operation. + err = createResourceGroupIfNotExists(ctx, r.Radius, providerConfig.Deployments.Value.Scope) + if err != nil { + return nil, fmt.Errorf("failed to create resource group: %w", err) + } + + deploymentName := fmt.Sprintf("deploymenttemplate-%v", uuid.New().String()) + resourceID := providerConfig.Deployments.Value.Scope + "/providers/" + "Microsoft.Resources/deployments" + "/" + deploymentName + + logger.Info("Starting PUT operation.") + poller, err := r.ResourceDeploymentsClient.CreateOrUpdate(ctx, + sdkclients.Deployment{ + Properties: &sdkclients.DeploymentProperties{ + Template: template, + Parameters: specParameters, + ProviderConfig: providerConfig, + Mode: armresources.DeploymentModeIncremental, + }, + }, + resourceID, + sdkclients.DeploymentsClientAPIVersion, + ) + if err != nil { + return nil, err + } else if poller != nil { + return poller, nil + } + + // Update was synchronous + return nil, nil +} + +func (r *DeploymentTemplateReconciler) requeueDelay() time.Duration { + delay := r.DelayInterval + if delay == 0 { + delay = PollingDelay + } + + return delay +} + +func ParseDeploymentScopeFromProviderConfig(providerConfig any) (string, error) { + var data []byte + switch v := providerConfig.(type) { + case string: + data = []byte(v) + case []byte: + data = v + default: + return "", fmt.Errorf("providerConfig must be a string or []byte, got %T", providerConfig) + } + + config := sdkclients.ProviderConfig{} + err := json.Unmarshal([]byte(data), &config) + if err != nil { + return "", fmt.Errorf("failed to unmarshal providerConfig: %w", err) + } + + if config.Deployments == nil { + return "", fmt.Errorf("providerConfig.Deployments is nil") + } + + return config.Deployments.Value.Scope, nil +} + +func isOwnedBy(resource radappiov1alpha3.DeploymentResource, owner *radappiov1alpha3.DeploymentTemplate) bool { + for _, ownerRef := range resource.OwnerReferences { + if ownerRef.Kind == "DeploymentTemplate" && ownerRef.Name == owner.Name { + return true + } + } + return false +} + +// computeHash computes a hash of the DeploymentTemplate's spec (desired state) +// to save in the status (observed state). +func computeHash(deploymentTemplate *radappiov1alpha3.DeploymentTemplate) (string, error) { + b, err := json.Marshal(deploymentTemplate.Spec) + if err != nil { + return "", err + } + + sum := sha1.Sum(b) + hash := hex.EncodeToString(sum[:]) + return hash, nil +} + +// isUpToDate returns true if the desired state of the DeploymentTemplate +// matches the observed state. +func isUpToDate(deploymentTemplate *radappiov1alpha3.DeploymentTemplate) bool { + hash, err := computeHash(deploymentTemplate) + if err != nil { + return false + } + + return deploymentTemplate.Status.StatusHash == hash +} + +// SetupWithManager sets up the controller with the Manager. +func (r *DeploymentTemplateReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&radappiov1alpha3.DeploymentTemplate{}). + Owns(&radappiov1alpha3.DeploymentResource{}). + Complete(r) +} diff --git a/pkg/controller/reconciler/deploymenttemplate_reconciler_test.go b/pkg/controller/reconciler/deploymenttemplate_reconciler_test.go new file mode 100644 index 0000000000..bc47bc6956 --- /dev/null +++ b/pkg/controller/reconciler/deploymenttemplate_reconciler_test.go @@ -0,0 +1,871 @@ +/* +Copyright 2024 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 reconciler + +import ( + "encoding/json" + "errors" + "os" + "path" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/test/testcontext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + crconfig "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +const ( + deploymentTemplateTestWaitDuration = time.Second * 10 + deploymentTemplateTestWaitInterval = time.Second * 1 + deploymentTemplateTestControllerDelayInterval = time.Millisecond * 100 +) + +func SetupDeploymentTemplateTest(t *testing.T) (*mockRadiusClient, *sdkclients.MockResourceDeploymentsClient, k8sclient.Client) { + SkipWithoutEnvironment(t) + + // For debugging, you can set uncomment this to see logs from the controller. This will cause tests to fail + // because the logging will continue after the test completes. + // + // Add runtimelog "sigs.k8s.io/controller-runtime/pkg/log" to imports. + // + // runtimelog.SetLogger(ucplog.FromContextOrDiscard(testcontext.New(t))) + + // Shut down the manager when the test exits. + ctx, cancel := testcontext.NewWithCancel(t) + t.Cleanup(cancel) + + mgr, err := ctrl.NewManager(config, ctrl.Options{ + Scheme: scheme, + Controller: crconfig.Controller{ + SkipNameValidation: to.Ptr(true), + }, + + // Suppress metrics in tests to avoid conflicts. + Metrics: server.Options{ + BindAddress: "0", + }, + }) + require.NoError(t, err) + + mockRadiusClient := NewMockRadiusClient() + mockResourceDeploymentsClient := sdkclients.NewMockResourceDeploymentsClient() + + // Set up DeploymentTemplateReconciler. + err = (&DeploymentTemplateReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventRecorder: mgr.GetEventRecorderFor("deploymenttemplate-controller"), + Radius: mockRadiusClient, + ResourceDeploymentsClient: mockResourceDeploymentsClient, + DelayInterval: deploymentTemplateTestControllerDelayInterval, + }).SetupWithManager(mgr) + require.NoError(t, err) + + // Set up DeploymentResourceReconciler. + err = (&DeploymentResourceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventRecorder: mgr.GetEventRecorderFor("deploymentresource-controller"), + Radius: mockRadiusClient, + ResourceDeploymentsClient: mockResourceDeploymentsClient, + DelayInterval: DeploymentResourceTestControllerDelayInterval, + }).SetupWithManager(mgr) + require.NoError(t, err) + + go func() { + err := mgr.Start(ctx) + require.NoError(t, err) + }() + + return mockRadiusClient, mockResourceDeploymentsClient, mgr.GetClient() +} + +func Test_DeploymentTemplateReconciler_ComputeHash(t *testing.T) { + testcases := []struct { + name string + deploymentTemplate *radappiov1alpha3.DeploymentTemplate + expected string + }{ + { + name: "empty", + deploymentTemplate: &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{}, + }, + expected: "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f", + }, + { + name: "simple", + deploymentTemplate: &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: "{}", + Parameters: map[string]string{}, + ProviderConfig: "{}", + }, + }, + expected: "47ee899e74561942ee36a02ffd80be955e251583", + }, + { + name: "complex", + deploymentTemplate: &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: `{"resources":[{"type":"Microsoft.Resources/deployments","apiVersion":"2020-06-01","name":"test-deploymenttemplate-basic","properties":{"mode":"Incremental","template":{},"parameters":{}}}]}`, + Parameters: map[string]string{"param1": "value1", "param2": "value2"}, + ProviderConfig: `{"AWS":{"type":"aws","value":{"scope":"scope"}}}`, + }, + }, + expected: "5c83b7122697599db2a47f2d5f7e29f4b9e3c869", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + hash, err := computeHash(tc.deploymentTemplate) + require.NoError(t, err) + require.Equal(t, tc.expected, hash) + }) + } +} + +func Test_DeploymentTemplateReconciler_IsUpToDate(t *testing.T) { + testcases := []struct { + name string + deploymentTemplate *radappiov1alpha3.DeploymentTemplate + expected bool + }{ + { + name: "up-to-date", + deploymentTemplate: &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: "{}", + Parameters: map[string]string{}, + ProviderConfig: "{}", + }, + Status: radappiov1alpha3.DeploymentTemplateStatus{ + StatusHash: "47ee899e74561942ee36a02ffd80be955e251583", + }, + }, + expected: true, + }, + { + name: "not-up-to-date", + deploymentTemplate: &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: "{}", + Parameters: map[string]string{}, + ProviderConfig: "{}", + }, + Status: radappiov1alpha3.DeploymentTemplateStatus{ + StatusHash: "incorrecthash", + }, + }, + expected: false, + }, + { + name: "complex-up-to-date", + deploymentTemplate: &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: `{"resources":[{"type":"Microsoft.Resources/deployments","apiVersion":"2020-06-01","name":"test-deploymenttemplate-basic","properties":{"mode":"Incremental","template":{},"parameters":{}}}]}`, + Parameters: map[string]string{"param1": "value1", "param2": "value2"}, + ProviderConfig: `{"AWS":{"type":"aws","value":{"scope":"scope"}}}`, + }, + Status: radappiov1alpha3.DeploymentTemplateStatus{ + StatusHash: "5c83b7122697599db2a47f2d5f7e29f4b9e3c869", + }, + }, + expected: true, + }, + { + name: "complex-not-up-to-date", + deploymentTemplate: &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: `{"resources":[{"type":"Microsoft.Resources/deployments","apiVersion":"2020-06-01","name":"test-deploymenttemplate-basic","properties":{"mode":"Incremental","template":{},"parameters":{}}}]}`, + Parameters: map[string]string{"param1": "value1", "param2": "value2"}, + ProviderConfig: `{"AWS":{"type":"aws","value":{"scope":"scope"}}}`, + }, + Status: radappiov1alpha3.DeploymentTemplateStatus{ + StatusHash: "incorrecthash", + }, + }, + expected: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + isUpToDate := isUpToDate(tc.deploymentTemplate) + require.Equal(t, tc.expected, isUpToDate) + }) + } +} + +func Test_ParseDeploymentScopeFromProviderConfig(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + providerConfig string + wantScope string + wantErr bool + }{ + { + name: "valid: provider with scope", + providerConfig: `{"deployments":{"type":"deployments","value":{"scope":"deploymentsscope"}}}`, + wantScope: "deploymentsscope", + wantErr: false, + }, + { + name: "invalid: deployments scope not present", + providerConfig: `{"radius":{"type":"radius","value":{"scope":"deploymentsscope"}}}`, + wantErr: true, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + scope, err := ParseDeploymentScopeFromProviderConfig(tc.providerConfig) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantScope, scope) + }) + } +} + +func Test_DeploymentTemplateReconciler_Basic(t *testing.T) { + // This test tests the basic functionality of the DeploymentTemplate controller. + // It creates a DeploymentTemplate (with an empty template field), + // waits for it to be ready, and then deletes it. + // + // This is the same structure as all of the following tests. + + ctx := testcontext.New(t) + + // Set up the test. + _, mockDeploymentClient, k8sClient := SetupDeploymentTemplateTest(t) + testNamespace := "deploymenttemplate-basic" + testName := "test-deploymenttemplate-basic" + template := "{}" + parameters := map[string]string{} + providerConfig, err := sdkclients.NewDefaultProviderConfig(testNamespace).String() + require.NoError(t, err) + + // Create k8s namespace for the test. + namespacedName := types.NamespacedName{Namespace: testNamespace, Name: testName} + err = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: testNamespace}}) + require.NoError(t, err) + + // Create the DeploymentTemplate resource. + deploymentTemplate := makeDeploymentTemplate(namespacedName, template, providerConfig, parameters) + err = k8sClient.Create(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to enter the Updating state. + status := waitForDeploymentTemplateStateUpdating(t, k8sClient, namespacedName, nil) + + // Complete the operation. + mockDeploymentClient.CompleteOperation(status.Operation.ResumeToken, nil) + + // DeploymentTemplate should be Ready after the operation completes. + status = waitForDeploymentTemplateStateReady(t, k8sClient, namespacedName) + + // Verify that the DeploymentTemplate desired state contains the expected properties. + expectedDeploymentTemplateSpec := &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: template, + Parameters: parameters, + ProviderConfig: providerConfig, + }, + } + expectedStatusHash, err := computeHash(expectedDeploymentTemplateSpec) + require.NoError(t, err) + require.Equal(t, expectedStatusHash, status.StatusHash) + + // Trigger deletion of the DeploymentTemplate. + err = k8sClient.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to be deleted. + waitForDeploymentTemplateStateDeleted(t, k8sClient, namespacedName) +} + +func Test_DeploymentTemplateReconciler_FailureRecovery(t *testing.T) { + // This test tests our ability to recover from failed operations inside Radius. + // + // We use the mock client to simulate the failure of update and delete operations + // and verify that the controller will (eventually) retry these operations. + + ctx := testcontext.New(t) + + // Set up the test. + _, mockDeploymentClient, k8sClient := SetupDeploymentTemplateTest(t) + testNamespace := "deploymenttemplate-failurerecovery" + testName := "test-deploymenttemplate-failurerecovery" + template := "{}" + parameters := map[string]string{} + providerConfig, err := sdkclients.NewDefaultProviderConfig(testNamespace).String() + require.NoError(t, err) + + // Create k8s namespace for the test. + namespacedName := types.NamespacedName{Namespace: testNamespace, Name: testName} + err = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: testNamespace}}) + require.NoError(t, err) + + // Create the DeploymentTemplate resource. + deploymentTemplate := makeDeploymentTemplate(namespacedName, template, providerConfig, parameters) + err = k8sClient.Create(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to enter the Updating state. + status := waitForDeploymentTemplateStateUpdating(t, k8sClient, namespacedName, nil) + + // Complete the operation, but make it fail. + operation := status.Operation + mockDeploymentClient.CompleteOperation(status.Operation.ResumeToken, func(state *sdkclients.OperationState) { + state.Err = errors.New("failure") + + resource, ok := mockDeploymentClient.GetResource(state.ResourceID) + require.True(t, ok, "failed to find resource") + + resource.Properties.ProvisioningState = to.Ptr(armresources.ProvisioningStateFailed) + state.Value = sdkclients.ClientCreateOrUpdateResponse{DeploymentExtended: armresources.DeploymentExtended{Properties: resource.Properties}} + }) + + // DeploymentTemplate should (eventually) start a new provisioning operation + status = waitForDeploymentTemplateStateUpdating(t, k8sClient, namespacedName, operation) + + // Complete the operation, successfully this time. + mockDeploymentClient.CompleteOperation(status.Operation.ResumeToken, nil) + status = waitForDeploymentTemplateStateReady(t, k8sClient, namespacedName) + + // Verify that the DeploymentTemplate desired state contains the expected properties. + expectedDeploymentTemplateSpec := &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: template, + Parameters: parameters, + ProviderConfig: providerConfig, + }, + } + expectedStatusHash, err := computeHash(expectedDeploymentTemplateSpec) + require.NoError(t, err) + require.Equal(t, expectedStatusHash, status.StatusHash) + + // Trigger deletion of the DeploymentTemplate. + err = k8sClient.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to be deleted. + waitForDeploymentTemplateStateDeleted(t, k8sClient, namespacedName) +} + +func Test_DeploymentTemplateReconciler_WithResources(t *testing.T) { + // This test tests the ability to handle deployments of + // resources created by the DeploymentTemplate. + + ctx := testcontext.New(t) + + // Set up the test. + _, mockDeploymentClient, k8sClient := SetupDeploymentTemplateTest(t) + testNamespace := "deploymenttemplate-withresources" + testName := "test-deploymenttemplate-withresources" + template := readFileIntoTemplate(t, "deploymenttemplate-withresources.json") + parameters := map[string]string{} + providerConfig, err := sdkclients.NewDefaultProviderConfig(testNamespace).String() + require.NoError(t, err) + + // Create k8s namespace for the test. + namespacedName := types.NamespacedName{Namespace: testNamespace, Name: testName} + err = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: testNamespace}}) + require.NoError(t, err) + + // Create the DeploymentTemplate resource. + deploymentTemplate := makeDeploymentTemplate(namespacedName, template, providerConfig, parameters) + err = k8sClient.Create(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to enter the Updating state. + status := waitForDeploymentTemplateStateUpdating(t, k8sClient, namespacedName, nil) + + // Complete the operation. + mockDeploymentClient.CompleteOperation(status.Operation.ResumeToken, func(state *sdkclients.OperationState) { + resource, ok := mockDeploymentClient.GetResource(state.ResourceID) + require.True(t, ok, "failed to find resource") + + resource.Properties.OutputResources = []*armresources.ResourceReference{ + {ID: to.Ptr("/planes/radius/local/resourceGroups/deploymenttemplate-update/providers/Applications.Core/environments/deploymenttemplate-withresources-env")}, + } + state.Value = sdkclients.ClientCreateOrUpdateResponse{DeploymentExtended: armresources.DeploymentExtended{Properties: resource.Properties}} + }) + + // DeploymentTemplate should be ready after the operation completes. + status = waitForDeploymentTemplateStateReady(t, k8sClient, namespacedName) + + // The dependencies (DeploymentResource resources) should be created. + dependencyName := types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-withresources-env"} + dependencyStatus := waitForDeploymentResourceStateReady(t, k8sClient, dependencyName) + require.Equal(t, "/planes/radius/local/resourceGroups/deploymenttemplate-update/providers/Applications.Core/environments/deploymenttemplate-withresources-env", dependencyStatus.Id) + + // Verify that the DeploymentTemplate desired state contains the expected properties. + expectedDeploymentTemplateSpec := &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: template, + Parameters: parameters, + ProviderConfig: providerConfig, + }, + } + expectedStatusHash, err := computeHash(expectedDeploymentTemplateSpec) + require.NoError(t, err) + require.Equal(t, expectedStatusHash, status.StatusHash) + + // Trigger deletion of the DeploymentTemplate. + err = k8sClient.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + // The DeploymentTemplate should be in the deleting state. + waitForDeploymentTemplateStateDeleting(t, k8sClient, namespacedName) + + // Get the status of the dependency (DeploymentResource resource). + dependencyStatus = waitForDeploymentResourceStateDeleting(t, k8sClient, dependencyName, nil) + + // Complete the delete operation on the DeploymentResource. + mockDeploymentClient.CompleteOperation(dependencyStatus.Operation.ResumeToken, nil) + + // Wait for the DeploymentTemplate to be deleted. + waitForDeploymentResourceDeleted(t, k8sClient, dependencyName) + waitForDeploymentTemplateStateDeleted(t, k8sClient, namespacedName) +} + +func Test_DeploymentTemplateReconciler_Update(t *testing.T) { + // This test tests our ability to update a DeploymentTemplate. + // We create a DeploymentTemplate, update it, and verify that the Radius resource is updated accordingly. + + ctx := testcontext.New(t) + + // Set up the test. + _, mockDeploymentClient, k8sClient := SetupDeploymentTemplateTest(t) + testNamespace := "deploymenttemplate-update" + testName := "test-deploymenttemplate-update" + template := readFileIntoTemplate(t, "deploymenttemplate-update-1.json") + parameters := map[string]string{} + providerConfig, err := sdkclients.NewDefaultProviderConfig(testNamespace).String() + require.NoError(t, err) + + // Create k8s namespace for the test. + namespacedName := types.NamespacedName{Namespace: testNamespace, Name: testName} + err = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: testNamespace}}) + require.NoError(t, err) + + // Create the DeploymentTemplate resource. + deploymentTemplate := makeDeploymentTemplate(namespacedName, template, providerConfig, parameters) + err = k8sClient.Create(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to enter the Updating state. + status := waitForDeploymentTemplateStateUpdating(t, k8sClient, namespacedName, nil) + + // Complete the operation. + mockDeploymentClient.CompleteOperation(status.Operation.ResumeToken, func(state *sdkclients.OperationState) { + resource, ok := mockDeploymentClient.GetResource(state.ResourceID) + require.True(t, ok, "failed to find resource") + + resource.Properties.OutputResources = []*armresources.ResourceReference{ + {ID: to.Ptr("/planes/radius/local/resourceGroups/deploymenttemplate-update/providers/Applications.Core/environments/deploymenttemplate-update-env")}, + } + state.Value = sdkclients.ClientCreateOrUpdateResponse{DeploymentExtended: armresources.DeploymentExtended{Properties: resource.Properties}} + }) + + // DeploymentTemplate should be ready after the operation completes. + status = waitForDeploymentTemplateStateReady(t, k8sClient, namespacedName) + + // The dependencies (DeploymentResource resources) should be created. + dependencyName := types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-update-env"} + dependencyStatus := waitForDeploymentResourceStateReady(t, k8sClient, dependencyName) + require.Equal(t, "/planes/radius/local/resourceGroups/deploymenttemplate-update/providers/Applications.Core/environments/deploymenttemplate-update-env", dependencyStatus.Id) + + // Verify that the DeploymentTemplate desired state contains the expected properties. + expectedDeploymentTemplateSpec := &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: template, + Parameters: parameters, + ProviderConfig: providerConfig, + }, + } + expectedStatusHash, err := computeHash(expectedDeploymentTemplateSpec) + require.NoError(t, err) + require.Equal(t, expectedStatusHash, status.StatusHash) + + // Now, we will re-deploy the DeploymentTemplate with a new template. + + // Get the DeploymentTemplate resource. + newDeploymentTemplate := radappiov1alpha3.DeploymentTemplate{} + err = k8sClient.Get(ctx, namespacedName, &newDeploymentTemplate) + require.NoError(t, err) + + // Update the template field on the DeploymentTemplate. + template = readFileIntoTemplate(t, "deploymenttemplate-update-2.json") + newDeploymentTemplate.Spec.Template = string(template) + + // Update the DeploymentTemplate resource. + err = k8sClient.Update(ctx, &newDeploymentTemplate) + require.NoError(t, err) + + // Now, the DeploymentTemplate should re-enter the Updating state. + status = waitForDeploymentTemplateStateUpdating(t, k8sClient, namespacedName, nil) + + // Complete the operation again. + mockDeploymentClient.CompleteOperation(status.Operation.ResumeToken, func(state *sdkclients.OperationState) { + resource, ok := mockDeploymentClient.GetResource(state.ResourceID) + require.True(t, ok, "failed to find resource") + + resource.Properties.OutputResources = []*armresources.ResourceReference{ + {ID: to.Ptr("/planes/radius/local/resourceGroups/deploymenttemplate-update/providers/Applications.Core/environments/deploymenttemplate-update-env")}, + } + state.Value = sdkclients.ClientCreateOrUpdateResponse{DeploymentExtended: armresources.DeploymentExtended{Properties: resource.Properties}} + }) + + // DeploymentTemplate should be Ready again after the operation completes. + status = waitForDeploymentTemplateStateReady(t, k8sClient, namespacedName) + + // The dependencies (DeploymentResource resources) should also be Ready. + dependencyName = types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-update-env"} + dependencyStatus = waitForDeploymentResourceStateReady(t, k8sClient, dependencyName) + require.Equal(t, "/planes/radius/local/resourceGroups/deploymenttemplate-update/providers/Applications.Core/environments/deploymenttemplate-update-env", dependencyStatus.Id) + + // Verify that the DeploymentTemplate contains the expected properties. + expectedDeploymentTemplateSpec = &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: template, + Parameters: parameters, + ProviderConfig: providerConfig, + }, + } + expectedStatusHash, err = computeHash(expectedDeploymentTemplateSpec) + require.NoError(t, err) + require.Equal(t, expectedStatusHash, status.StatusHash) + + // Trigger deletion of the DeploymentTemplate. + err = k8sClient.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + // The DeploymentTemplate should be in the deleting state. + waitForDeploymentTemplateStateDeleting(t, k8sClient, namespacedName) + + // Get the status of the dependency (DeploymentResource resource). + dependencyStatus = waitForDeploymentResourceStateDeleting(t, k8sClient, dependencyName, nil) + + // Complete the delete operation on the DeploymentResource. + mockDeploymentClient.CompleteOperation(dependencyStatus.Operation.ResumeToken, nil) + + // Wait for the DeploymentTemplate to be deleted. + waitForDeploymentResourceDeleted(t, k8sClient, dependencyName) + waitForDeploymentTemplateStateDeleted(t, k8sClient, namespacedName) +} + +func Test_DeploymentTemplateReconciler_OutputResources(t *testing.T) { + // This test tests the ability to perform diff detection on + // the OutputResources field of the DeploymentTemplate. + // We create a DeploymentTemplate with some resources, + // update the DeploymentTemplate to remove some resources, + // and verify that the diff is correct. + + ctx := testcontext.New(t) + + // Set up the test. + _, mockDeploymentClient, k8sClient := SetupDeploymentTemplateTest(t) + testNamespace := "deploymenttemplate-outputresources" + testName := "test-deploymenttemplate-outputresources" + template := readFileIntoTemplate(t, "deploymenttemplate-outputresources-1.json") + parameters := map[string]string{} + providerConfig, err := sdkclients.NewDefaultProviderConfig(testNamespace).String() + require.NoError(t, err) + + // Create k8s namespace for the test. + namespacedName := types.NamespacedName{Namespace: testNamespace, Name: testName} + err = k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: testNamespace}}) + require.NoError(t, err) + + // Create the DeploymentTemplate resource. + deploymentTemplate := makeDeploymentTemplate(namespacedName, template, providerConfig, parameters) + err = k8sClient.Create(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to enter the Updating state. + status := waitForDeploymentTemplateStateUpdating(t, k8sClient, namespacedName, nil) + + // Complete the operation. + mockDeploymentClient.CompleteOperation(status.Operation.ResumeToken, func(state *sdkclients.OperationState) { + resource, ok := mockDeploymentClient.GetResource(state.ResourceID) + require.True(t, ok, "failed to find resource") + + resource.Properties.OutputResources = []*armresources.ResourceReference{ + {ID: to.Ptr("/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/environments/deploymenttemplate-outputresources-environment")}, + {ID: to.Ptr("/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/applications/deploymenttemplate-outputresources-application")}, + {ID: to.Ptr("/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/containers/deploymenttemplate-outputresources-container")}, + } + state.Value = sdkclients.ClientCreateOrUpdateResponse{DeploymentExtended: armresources.DeploymentExtended{Properties: resource.Properties}} + }) + + // DeploymentTemplate should be ready after the operation completes. + status = waitForDeploymentTemplateStateReady(t, k8sClient, namespacedName) + + // The dependencies (DeploymentResource resources) should be created. + dependencies := []struct { + resourceID string + namespacedName types.NamespacedName + }{ + { + resourceID: "/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/environments/deploymenttemplate-outputresources-environment", + namespacedName: types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-outputresources-environment"}, + }, + { + resourceID: "/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/applications/deploymenttemplate-outputresources-application", + namespacedName: types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-outputresources-application"}, + }, + { + resourceID: "/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/containers/deploymenttemplate-outputresources-container", + namespacedName: types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-outputresources-container"}, + }, + } + for _, dependency := range dependencies { + dependencyStatus := waitForDeploymentResourceStateReady(t, k8sClient, dependency.namespacedName) + require.Equal(t, dependency.resourceID, dependencyStatus.Id) + } + + // Verify that the DeploymentTemplate desired state contains the expected properties. + expectedDeploymentTemplateSpec := &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: template, + Parameters: parameters, + ProviderConfig: providerConfig, + }, + } + expectedStatusHash, err := computeHash(expectedDeploymentTemplateSpec) + require.NoError(t, err) + require.Equal(t, expectedStatusHash, status.StatusHash) + + // Now, we will re-deploy the DeploymentTemplate with a new template. + // This template has the container resource removed. + + // Get the DeploymentTemplate resource. + newDeploymentTemplate := radappiov1alpha3.DeploymentTemplate{} + err = k8sClient.Get(ctx, namespacedName, &newDeploymentTemplate) + require.NoError(t, err) + + // Update the template field on the DeploymentTemplate. + template = readFileIntoTemplate(t, "deploymenttemplate-outputresources-2.json") + newDeploymentTemplate.Spec.Template = string(template) + + // Update the DeploymentTemplate resource. + err = k8sClient.Update(ctx, &newDeploymentTemplate) + require.NoError(t, err) + + // Now, the DeploymentTemplate should re-enter the Updating state. + status = waitForDeploymentTemplateStateUpdating(t, k8sClient, namespacedName, nil) + + // Complete the operation again, with a different set of output resources. + mockDeploymentClient.CompleteOperation(status.Operation.ResumeToken, func(state *sdkclients.OperationState) { + resource, ok := mockDeploymentClient.GetResource(state.ResourceID) + require.True(t, ok, "failed to find resource") + + resource.Properties.OutputResources = []*armresources.ResourceReference{ + {ID: to.Ptr("/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/environments/deploymenttemplate-outputresources-environment")}, + {ID: to.Ptr("/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/applications/deploymenttemplate-outputresources-application")}, + } + state.Value = sdkclients.ClientCreateOrUpdateResponse{DeploymentExtended: armresources.DeploymentExtended{Properties: resource.Properties}} + }) + + // Complete the delete operation on the container resource. + dependencyName := types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-outputresources-container"} + dependencyStatus := waitForDeploymentResourceStateDeleting(t, k8sClient, dependencyName, nil) + mockDeploymentClient.CompleteOperation(dependencyStatus.Operation.ResumeToken, nil) + + // DeploymentTemplate should be Ready again after the operation completes. + status = waitForDeploymentTemplateStateReady(t, k8sClient, namespacedName) + + // The dependencies (DeploymentResource resources) should be in the Ready state. + dependencies = []struct { + resourceID string + namespacedName types.NamespacedName + }{ + { + resourceID: "/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/environments/deploymenttemplate-outputresources-environment", + namespacedName: types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-outputresources-environment"}, + }, + { + resourceID: "/planes/radius/local/resourceGroups/deploymenttemplate-outputresources/providers/Applications.Core/applications/deploymenttemplate-outputresources-application", + namespacedName: types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-outputresources-application"}, + }, + } + for _, dependency := range dependencies { + dependencyStatus := waitForDeploymentResourceStateReady(t, k8sClient, dependency.namespacedName) + require.Equal(t, dependency.resourceID, dependencyStatus.Id) + } + + // Assert that the container resource has been deleted. + dependencyName = types.NamespacedName{Namespace: namespacedName.Namespace, Name: "deploymenttemplate-outputresources-container"} + waitForDeploymentResourceDeleted(t, k8sClient, dependencyName) + + // Check the cluster for the container resource, it should not exist. + err = k8sClient.Get(ctx, dependencyName, &radappiov1alpha3.DeploymentResource{}) + require.True(t, apierrors.IsNotFound(err), "expected DeploymentResource to be deleted") + + // Verify that the DeploymentTemplate contains the expected properties. + expectedDeploymentTemplateSpec = &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: template, + Parameters: map[string]string{}, + ProviderConfig: providerConfig, + }, + } + expectedStatusHash, err = computeHash(expectedDeploymentTemplateSpec) + require.NoError(t, err) + require.Equal(t, expectedStatusHash, status.StatusHash) + + // Trigger deletion of the DeploymentTemplate. + err = k8sClient.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + // The DeploymentTemplate should be in the deleting state. + waitForDeploymentTemplateStateDeleting(t, k8sClient, namespacedName) + + // Wait for all of the dependencies (DeploymentResource resources) to be deleted. + for _, dependency := range dependencies { + dependencyStatus := waitForDeploymentResourceStateDeleting(t, k8sClient, dependency.namespacedName, nil) + mockDeploymentClient.CompleteOperation(dependencyStatus.Operation.ResumeToken, nil) + waitForDeploymentResourceDeleted(t, k8sClient, dependency.namespacedName) + } + + // Wait for the DeploymentTemplate to be deleted. + waitForDeploymentTemplateStateDeleted(t, k8sClient, namespacedName) +} + +func waitForDeploymentTemplateStateUpdating(t *testing.T, client k8sclient.Client, name types.NamespacedName, oldOperation *radappiov1alpha3.ResourceOperation) *radappiov1alpha3.DeploymentTemplateStatus { + ctx := testcontext.New(t) + + logger := t + status := &radappiov1alpha3.DeploymentTemplateStatus{} + require.EventuallyWithT(t, func(t *assert.CollectT) { + logger.Logf("Fetching DeploymentTemplate: %+v", name) + current := &radappiov1alpha3.DeploymentTemplate{ + Status: radappiov1alpha3.DeploymentTemplateStatus{ + Phrase: radappiov1alpha3.DeploymentTemplatePhrase(radappiov1alpha3.DeploymentResourcePhraseDeleting), + }, + } + err := client.Get(ctx, name, current) + require.NoError(t, err) + + status = ¤t.Status + logger.Logf("DeploymentTemplate.Status: %+v", current.Status) + assert.Equal(t, status.ObservedGeneration, current.Generation, "Status is not updated") + + if assert.Equal(t, radappiov1alpha3.DeploymentTemplatePhraseUpdating, current.Status.Phrase) { + assert.NotEmpty(t, current.Status.Operation) + assert.NotEqual(t, oldOperation, current.Status.Operation) + } + + }, deploymentTemplateTestWaitDuration, deploymentTemplateTestWaitInterval, "failed to enter updating state") + + return status +} + +func waitForDeploymentTemplateStateReady(t *testing.T, client k8sclient.Client, name types.NamespacedName) *radappiov1alpha3.DeploymentTemplateStatus { + ctx := testcontext.New(t) + + logger := t + status := &radappiov1alpha3.DeploymentTemplateStatus{} + require.EventuallyWithTf(t, func(t *assert.CollectT) { + logger.Logf("Fetching DeploymentTemplate: %+v", name) + current := &radappiov1alpha3.DeploymentTemplate{} + err := client.Get(ctx, name, current) + require.NoError(t, err) + + status = ¤t.Status + logger.Logf("DeploymentTemplate.Status: %+v", current.Status) + assert.Equal(t, status.ObservedGeneration, current.Generation, "Status is not updated") + + if assert.Equal(t, radappiov1alpha3.DeploymentTemplatePhraseReady, current.Status.Phrase) { + assert.Empty(t, current.Status.Operation) + } + }, deploymentTemplateTestWaitDuration, deploymentTemplateTestWaitInterval, "failed to enter ready state") + + return status +} + +func waitForDeploymentTemplateStateDeleting(t *testing.T, client k8sclient.Client, name types.NamespacedName) *radappiov1alpha3.DeploymentTemplateStatus { + ctx := testcontext.New(t) + + logger := t + status := &radappiov1alpha3.DeploymentTemplateStatus{} + require.EventuallyWithTf(t, func(t *assert.CollectT) { + logger.Logf("Fetching DeploymentTemplate: %+v", name) + current := &radappiov1alpha3.DeploymentTemplate{} + err := client.Get(ctx, name, current) + assert.NoError(t, err) + + status = ¤t.Status + logger.Logf("DeploymentTemplate.Status: %+v", current.Status) + assert.Equal(t, status.ObservedGeneration, current.Generation, "Status is not updated") + + assert.Equal(t, radappiov1alpha3.DeploymentTemplatePhraseDeleting, current.Status.Phrase) + }, deploymentTemplateTestWaitDuration, deploymentTemplateTestWaitInterval, "failed to enter deleting state") + + return status +} + +func waitForDeploymentTemplateStateDeleted(t *testing.T, client k8sclient.Client, name types.NamespacedName) { + ctx := testcontext.New(t) + + logger := t + require.Eventuallyf(t, func() bool { + logger.Logf("Fetching DeploymentTemplate: %+v", name) + current := &radappiov1alpha3.DeploymentTemplate{} + err := client.Get(ctx, name, current) + if apierrors.IsNotFound(err) { + return true + } + + logger.Logf("DeploymentTemplate.Status: %+v", current.Status) + return false + + }, deploymentTemplateTestWaitDuration, deploymentTemplateTestWaitInterval, "DeploymentTemplate still exists") +} + +func readFileIntoTemplate(t *testing.T, filename string) string { + fileContent, err := os.ReadFile(path.Join("testdata", filename)) + require.NoError(t, err) + templateMap := map[string]any{} + err = json.Unmarshal(fileContent, &templateMap) + require.NoError(t, err) + template, err := json.MarshalIndent(templateMap, "", " ") + require.NoError(t, err) + return string(template) +} diff --git a/pkg/controller/reconciler/mock_client_test.go b/pkg/controller/reconciler/mock_radius_client_test.go similarity index 75% rename from pkg/controller/reconciler/mock_client_test.go rename to pkg/controller/reconciler/mock_radius_client_test.go index 08bd96c2e8..de69cacea1 100644 --- a/pkg/controller/reconciler/mock_client_test.go +++ b/pkg/controller/reconciler/mock_radius_client_test.go @@ -23,10 +23,12 @@ import ( "sync" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/google/uuid" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/cli/clients_new/generated" corerpv20231001preview "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" "github.com/radius-project/radius/pkg/to" ucpv20231001preview "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" ) @@ -40,7 +42,7 @@ func NewMockRadiusClient() *mockRadiusClient { environments: map[string]corerpv20231001preview.EnvironmentResource{}, groups: map[string]ucpv20231001preview.ResourceGroupResource{}, resources: map[string]generated.GenericResource{}, - operations: map[string]*operationState{}, + operations: map[string]*sdkclients.OperationState{}, lock: &sync.Mutex{}, } @@ -54,21 +56,19 @@ type mockRadiusClient struct { environments map[string]corerpv20231001preview.EnvironmentResource groups map[string]ucpv20231001preview.ResourceGroupResource resources map[string]generated.GenericResource - operations map[string]*operationState + operations map[string]*sdkclients.OperationState lock *sync.Mutex } -type operationState struct { - complete bool - value any - // Ideally we'd use azcore.ResponseError here, but it's tricky to set up in tests. - err error - resourceID string - kind string +func (rc *mockRadiusClient) Update(exec func()) { + rc.lock.Lock() + defer rc.lock.Unlock() + + exec() } -func (rc *mockRadiusClient) Update(exec func()) { +func (rc *mockRadiusClient) Delete(exec func()) { rc.lock.Lock() defer rc.lock.Unlock() @@ -95,7 +95,7 @@ func (rc *mockRadiusClient) Resources(scope string, resourceType string) Resourc return &mockResourceClient{mock: rc, scope: scope, resourceType: resourceType} } -func (rc *mockRadiusClient) CompleteOperation(operationID string, update func(state *operationState)) { +func (rc *mockRadiusClient) CompleteOperation(operationID string, update func(state *sdkclients.OperationState)) { rc.lock.Lock() defer rc.lock.Unlock() @@ -108,14 +108,14 @@ func (rc *mockRadiusClient) CompleteOperation(operationID string, update func(st update(state) } - state.complete = true + state.Complete = true - if state.kind == http.MethodDelete { - delete(rc.environments, state.resourceID) - delete(rc.applications, state.resourceID) - delete(rc.containers, state.resourceID) - delete(rc.groups, state.resourceID) - delete(rc.resources, state.resourceID) + if state.Kind == http.MethodDelete { + delete(rc.environments, state.ResourceID) + delete(rc.applications, state.ResourceID) + delete(rc.containers, state.ResourceID) + delete(rc.groups, state.ResourceID) + delete(rc.resources, state.ResourceID) } } @@ -176,38 +176,38 @@ func (cc *mockContainerClient) id(containerName string) string { return cc.scope + "/providers/Applications.Core/containers/" + containerName } -func (cc *mockContainerClient) BeginCreateOrUpdate(ctx context.Context, containerName string, resource corerpv20231001preview.ContainerResource, options *corerpv20231001preview.ContainersClientBeginCreateOrUpdateOptions) (Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { +func (cc *mockContainerClient) BeginCreateOrUpdate(ctx context.Context, containerName string, resource corerpv20231001preview.ContainerResource, options *corerpv20231001preview.ContainersClientBeginCreateOrUpdateOptions) (sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { id := cc.id(containerName) cc.mock.lock.Lock() defer cc.mock.lock.Unlock() value := corerpv20231001preview.ContainersClientCreateOrUpdateResponse{ContainerResource: resource} - state := &operationState{kind: http.MethodPut, value: value, resourceID: id} + state := &sdkclients.OperationState{Kind: http.MethodPut, Value: value, ResourceID: id} operationID := uuid.New().String() cc.mock.containers[id] = resource cc.mock.operations[operationID] = state - return &mockPoller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse]{mock: cc.mock, operationID: operationID, state: state}, nil + return &mockRadiusClientPoller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse]{mock: cc.mock, operationID: operationID, state: state}, nil } -func (cc *mockContainerClient) BeginDelete(ctx context.Context, containerName string, options *corerpv20231001preview.ContainersClientBeginDeleteOptions) (Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { +func (cc *mockContainerClient) BeginDelete(ctx context.Context, containerName string, options *corerpv20231001preview.ContainersClientBeginDeleteOptions) (sdkclients.Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { id := cc.id(containerName) cc.mock.lock.Lock() defer cc.mock.lock.Unlock() value := corerpv20231001preview.ContainersClientDeleteResponse{} - state := &operationState{kind: http.MethodDelete, value: value, resourceID: id} + state := &sdkclients.OperationState{Kind: http.MethodDelete, Value: value, ResourceID: id} operationID := uuid.New().String() cc.mock.operations[operationID] = state - return &mockPoller[corerpv20231001preview.ContainersClientDeleteResponse]{mock: cc.mock, operationID: operationID, state: state}, nil + return &mockRadiusClientPoller[corerpv20231001preview.ContainersClientDeleteResponse]{mock: cc.mock, operationID: operationID, state: state}, nil } -func (cc *mockContainerClient) ContinueCreateOperation(ctx context.Context, resumeToken string) (Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { +func (cc *mockContainerClient) ContinueCreateOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { cc.mock.lock.Lock() defer cc.mock.lock.Unlock() @@ -216,10 +216,10 @@ func (cc *mockContainerClient) ContinueCreateOperation(ctx context.Context, resu panic("operation not found: " + resumeToken) } - return &mockPoller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse]{mock: cc.mock, operationID: resumeToken, state: state}, nil + return &mockRadiusClientPoller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse]{mock: cc.mock, operationID: resumeToken, state: state}, nil } -func (cc *mockContainerClient) ContinueDeleteOperation(ctx context.Context, resumeToken string) (Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { +func (cc *mockContainerClient) ContinueDeleteOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { cc.mock.lock.Lock() defer cc.mock.lock.Unlock() @@ -228,7 +228,7 @@ func (cc *mockContainerClient) ContinueDeleteOperation(ctx context.Context, resu panic("operation not found: " + resumeToken) } - return &mockPoller[corerpv20231001preview.ContainersClientDeleteResponse]{mock: cc.mock, operationID: resumeToken, state: state}, nil + return &mockRadiusClientPoller[corerpv20231001preview.ContainersClientDeleteResponse]{mock: cc.mock, operationID: resumeToken, state: state}, nil } func (cc *mockContainerClient) Get(ctx context.Context, containerName string, options *corerpv20231001preview.ContainersClientGetOptions) (corerpv20231001preview.ContainersClientGetResponse, error) { @@ -313,38 +313,38 @@ func (rc *mockResourceClient) id(resourceName string) string { return rc.scope + "/providers/" + rc.resourceType + "/" + resourceName } -func (rc *mockResourceClient) BeginCreateOrUpdate(ctx context.Context, resourceName string, resource generated.GenericResource, options *generated.GenericResourcesClientBeginCreateOrUpdateOptions) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { +func (rc *mockResourceClient) BeginCreateOrUpdate(ctx context.Context, resourceName string, resource generated.GenericResource, options *generated.GenericResourcesClientBeginCreateOrUpdateOptions) (sdkclients.Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { id := rc.id(resourceName) rc.mock.lock.Lock() defer rc.mock.lock.Unlock() value := generated.GenericResourcesClientCreateOrUpdateResponse{GenericResource: resource} - state := &operationState{kind: http.MethodPut, value: value, resourceID: id} + state := &sdkclients.OperationState{Kind: http.MethodPut, Value: value, ResourceID: id} operationID := uuid.New().String() rc.mock.resources[id] = resource rc.mock.operations[operationID] = state - return &mockPoller[generated.GenericResourcesClientCreateOrUpdateResponse]{mock: rc.mock, operationID: operationID, state: state}, nil + return &mockRadiusClientPoller[generated.GenericResourcesClientCreateOrUpdateResponse]{mock: rc.mock, operationID: operationID, state: state}, nil } -func (rc *mockResourceClient) BeginDelete(ctx context.Context, resourceName string, options *generated.GenericResourcesClientBeginDeleteOptions) (Poller[generated.GenericResourcesClientDeleteResponse], error) { +func (rc *mockResourceClient) BeginDelete(ctx context.Context, resourceName string, options *generated.GenericResourcesClientBeginDeleteOptions) (sdkclients.Poller[generated.GenericResourcesClientDeleteResponse], error) { id := rc.id(resourceName) rc.mock.lock.Lock() defer rc.mock.lock.Unlock() value := generated.GenericResourcesClientDeleteResponse{} - state := &operationState{kind: http.MethodDelete, value: value, resourceID: id} + state := &sdkclients.OperationState{Kind: http.MethodDelete, Value: value, ResourceID: id} operationID := uuid.New().String() rc.mock.operations[operationID] = state - return &mockPoller[generated.GenericResourcesClientDeleteResponse]{mock: rc.mock, operationID: operationID, state: state}, nil + return &mockRadiusClientPoller[generated.GenericResourcesClientDeleteResponse]{mock: rc.mock, operationID: operationID, state: state}, nil } -func (rc *mockResourceClient) ContinueCreateOperation(ctx context.Context, resumeToken string) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { +func (rc *mockResourceClient) ContinueCreateOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { rc.mock.lock.Lock() defer rc.mock.lock.Unlock() @@ -353,10 +353,10 @@ func (rc *mockResourceClient) ContinueCreateOperation(ctx context.Context, resum panic("operation not found: " + resumeToken) } - return &mockPoller[generated.GenericResourcesClientCreateOrUpdateResponse]{mock: rc.mock, operationID: resumeToken, state: state}, nil + return &mockRadiusClientPoller[generated.GenericResourcesClientCreateOrUpdateResponse]{mock: rc.mock, operationID: resumeToken, state: state}, nil } -func (rc *mockResourceClient) ContinueDeleteOperation(ctx context.Context, resumeToken string) (Poller[generated.GenericResourcesClientDeleteResponse], error) { +func (rc *mockResourceClient) ContinueDeleteOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[generated.GenericResourcesClientDeleteResponse], error) { rc.mock.lock.Lock() defer rc.mock.lock.Unlock() @@ -365,7 +365,7 @@ func (rc *mockResourceClient) ContinueDeleteOperation(ctx context.Context, resum panic("operation not found: " + resumeToken) } - return &mockPoller[generated.GenericResourcesClientDeleteResponse]{mock: rc.mock, operationID: resumeToken, state: state}, nil + return &mockRadiusClientPoller[generated.GenericResourcesClientDeleteResponse]{mock: rc.mock, operationID: resumeToken, state: state}, nil } func (rc *mockResourceClient) Get(ctx context.Context, resourceName string) (generated.GenericResourcesClientGetResponse, error) { @@ -410,22 +410,22 @@ func (rc *mockResourceClient) ListSecrets(ctx context.Context, resourceName stri return generated.GenericResourcesClientListSecretsResponse{Value: secrets}, nil } -var _ Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse] = (*mockPoller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse])(nil) +var _ sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse] = (*mockRadiusClientPoller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse])(nil) -type mockPoller[T any] struct { +type mockRadiusClientPoller[T any] struct { operationID string mock *mockRadiusClient - state *operationState + state *sdkclients.OperationState } -func (mp *mockPoller[T]) Done() bool { +func (mp *mockRadiusClientPoller[T]) Done() bool { mp.mock.lock.Lock() defer mp.mock.lock.Unlock() - return mp.state.complete // Status updates are delivered via the Poll function. + return mp.state.Complete // Status updates are delivered via the Poll function. } -func (mp *mockPoller[T]) Poll(ctx context.Context) (*http.Response, error) { +func (mp *mockRadiusClientPoller[T]) Poll(ctx context.Context) (*http.Response, error) { mp.mock.lock.Lock() defer mp.mock.lock.Unlock() @@ -434,19 +434,23 @@ func (mp *mockPoller[T]) Poll(ctx context.Context) (*http.Response, error) { return nil, nil } -func (mp *mockPoller[T]) Result(ctx context.Context) (T, error) { +func (mp *mockRadiusClientPoller[T]) Result(ctx context.Context) (T, error) { mp.mock.lock.Lock() defer mp.mock.lock.Unlock() - if mp.state.complete && mp.state.err != nil { - return mp.state.value.(T), mp.state.err - } else if mp.state.complete { - return mp.state.value.(T), nil + if mp.state.Complete && mp.state.Err != nil { + return mp.state.Value.(T), mp.state.Err + } else if mp.state.Complete { + return mp.state.Value.(T), nil } panic("operation not done") } -func (mp *mockPoller[T]) ResumeToken() (string, error) { +func (mp *mockRadiusClientPoller[T]) ResumeToken() (string, error) { return mp.operationID, nil } + +func (mp *mockRadiusClientPoller[T]) PollUntilDone(ctx context.Context, options *azcoreruntime.PollUntilDoneOptions) (T, error) { + return mp.Result(ctx) +} diff --git a/pkg/controller/reconciler/client.go b/pkg/controller/reconciler/radius_client.go similarity index 81% rename from pkg/controller/reconciler/client.go rename to pkg/controller/reconciler/radius_client.go index fba15025ab..dae6108b9c 100644 --- a/pkg/controller/reconciler/client.go +++ b/pkg/controller/reconciler/radius_client.go @@ -1,5 +1,5 @@ /* -Copyright 2023. +Copyright 2024 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. @@ -18,26 +18,16 @@ package reconciler import ( "context" - "net/http" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/cli/clients_new/generated" corerpv20231001preview "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/sdk" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" ucpv20231001preview "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/resources" ) -type Poller[T any] interface { - Done() bool - Poll(ctx context.Context) (*http.Response, error) - Result(ctx context.Context) (T, error) - ResumeToken() (string, error) -} - -var _ Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse] = (*runtime.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse])(nil) - type RadiusClient interface { Applications(scope string) ApplicationClient Containers(scope string) ContainerClient @@ -53,10 +43,10 @@ type ApplicationClient interface { } type ContainerClient interface { - BeginCreateOrUpdate(ctx context.Context, containerName string, resource corerpv20231001preview.ContainerResource, options *corerpv20231001preview.ContainersClientBeginCreateOrUpdateOptions) (Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) - BeginDelete(ctx context.Context, containerName string, options *corerpv20231001preview.ContainersClientBeginDeleteOptions) (Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) - ContinueCreateOperation(ctx context.Context, resumeToken string) (Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) - ContinueDeleteOperation(ctx context.Context, resumeToken string) (Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) + BeginCreateOrUpdate(ctx context.Context, containerName string, resource corerpv20231001preview.ContainerResource, options *corerpv20231001preview.ContainersClientBeginCreateOrUpdateOptions) (sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) + BeginDelete(ctx context.Context, containerName string, options *corerpv20231001preview.ContainersClientBeginDeleteOptions) (sdkclients.Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) + ContinueCreateOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) + ContinueDeleteOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) Get(ctx context.Context, containerName string, options *corerpv20231001preview.ContainersClientGetOptions) (corerpv20231001preview.ContainersClientGetResponse, error) } @@ -70,25 +60,25 @@ type ResourceGroupClient interface { } type ResourceClient interface { - BeginCreateOrUpdate(ctx context.Context, resourceName string, resource generated.GenericResource, options *generated.GenericResourcesClientBeginCreateOrUpdateOptions) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) - BeginDelete(ctx context.Context, resourceName string, options *generated.GenericResourcesClientBeginDeleteOptions) (Poller[generated.GenericResourcesClientDeleteResponse], error) - ContinueCreateOperation(ctx context.Context, resumeToken string) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) - ContinueDeleteOperation(ctx context.Context, resumeToken string) (Poller[generated.GenericResourcesClientDeleteResponse], error) + BeginCreateOrUpdate(ctx context.Context, resourceName string, resource generated.GenericResource, options *generated.GenericResourcesClientBeginCreateOrUpdateOptions) (sdkclients.Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) + BeginDelete(ctx context.Context, resourceName string, options *generated.GenericResourcesClientBeginDeleteOptions) (sdkclients.Poller[generated.GenericResourcesClientDeleteResponse], error) + ContinueCreateOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) + ContinueDeleteOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[generated.GenericResourcesClientDeleteResponse], error) Get(ctx context.Context, resourceName string) (generated.GenericResourcesClientGetResponse, error) ListSecrets(ctx context.Context, resourceName string) (generated.GenericResourcesClientListSecretsResponse, error) } -type Client struct { +type RadiusClientImpl struct { connection sdk.Connection } -func NewClient(connection sdk.Connection) *Client { - return &Client{connection: connection} +func NewRadiusClient(connection sdk.Connection) *RadiusClientImpl { + return &RadiusClientImpl{connection: connection} } -var _ RadiusClient = (*Client)(nil) +var _ RadiusClient = (*RadiusClientImpl)(nil) -func (c *Client) Applications(scope string) ApplicationClient { +func (c *RadiusClientImpl) Applications(scope string) ApplicationClient { ac, err := corerpv20231001preview.NewApplicationsClient(scope, &aztoken.AnonymousCredential{}, sdk.NewClientOptions(c.connection)) if err != nil { panic("failed to create client: " + err.Error()) @@ -97,7 +87,7 @@ func (c *Client) Applications(scope string) ApplicationClient { return &ApplicationClientImpl{inner: ac} } -func (c *Client) Containers(scope string) ContainerClient { +func (c *RadiusClientImpl) Containers(scope string) ContainerClient { cc, err := corerpv20231001preview.NewContainersClient(scope, &aztoken.AnonymousCredential{}, sdk.NewClientOptions(c.connection)) if err != nil { panic("failed to create client: " + err.Error()) @@ -106,7 +96,7 @@ func (c *Client) Containers(scope string) ContainerClient { return &ContainerClientImpl{inner: cc} } -func (c *Client) Environments(scope string) EnvironmentClient { +func (c *RadiusClientImpl) Environments(scope string) EnvironmentClient { ec, err := corerpv20231001preview.NewEnvironmentsClient(scope, &aztoken.AnonymousCredential{}, sdk.NewClientOptions(c.connection)) if err != nil { panic("failed to create client: " + err.Error()) @@ -115,7 +105,7 @@ func (c *Client) Environments(scope string) EnvironmentClient { return &EnvironmentClientImpl{inner: ec} } -func (c *Client) Groups(scope string) ResourceGroupClient { +func (c *RadiusClientImpl) Groups(scope string) ResourceGroupClient { rgc, err := ucpv20231001preview.NewResourceGroupsClient(&aztoken.AnonymousCredential{}, sdk.NewClientOptions(c.connection)) if err != nil { panic("failed to create client: " + err.Error()) @@ -124,7 +114,7 @@ func (c *Client) Groups(scope string) ResourceGroupClient { return &ResourceGroupClientImpl{inner: rgc, scope: scope} } -func (c *Client) Resources(scope string, resourceType string) ResourceClient { +func (c *RadiusClientImpl) Resources(scope string, resourceType string) ResourceClient { gc, err := generated.NewGenericResourcesClient(scope, resourceType, &aztoken.AnonymousCredential{}, sdk.NewClientOptions(c.connection)) if err != nil { panic("failed to create client: " + err.Error()) @@ -157,19 +147,19 @@ type ContainerClientImpl struct { inner *corerpv20231001preview.ContainersClient } -func (cc *ContainerClientImpl) BeginCreateOrUpdate(ctx context.Context, containerName string, resource corerpv20231001preview.ContainerResource, options *corerpv20231001preview.ContainersClientBeginCreateOrUpdateOptions) (Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { +func (cc *ContainerClientImpl) BeginCreateOrUpdate(ctx context.Context, containerName string, resource corerpv20231001preview.ContainerResource, options *corerpv20231001preview.ContainersClientBeginCreateOrUpdateOptions) (sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { return cc.inner.BeginCreateOrUpdate(ctx, containerName, resource, options) } -func (cc *ContainerClientImpl) BeginDelete(ctx context.Context, containerName string, options *corerpv20231001preview.ContainersClientBeginDeleteOptions) (Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { +func (cc *ContainerClientImpl) BeginDelete(ctx context.Context, containerName string, options *corerpv20231001preview.ContainersClientBeginDeleteOptions) (sdkclients.Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { return cc.inner.BeginDelete(ctx, containerName, options) } -func (cc *ContainerClientImpl) ContinueCreateOperation(ctx context.Context, resumeToken string) (Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { +func (cc *ContainerClientImpl) ContinueCreateOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { return cc.inner.BeginCreateOrUpdate(ctx, "", corerpv20231001preview.ContainerResource{}, &corerpv20231001preview.ContainersClientBeginCreateOrUpdateOptions{ResumeToken: resumeToken}) } -func (cc *ContainerClientImpl) ContinueDeleteOperation(ctx context.Context, resumeToken string) (Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { +func (cc *ContainerClientImpl) ContinueDeleteOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { return cc.inner.BeginDelete(ctx, "", &corerpv20231001preview.ContainersClientBeginDeleteOptions{ResumeToken: resumeToken}) } @@ -227,19 +217,19 @@ type ResourceClientImpl struct { inner *generated.GenericResourcesClient } -func (rc *ResourceClientImpl) BeginCreateOrUpdate(ctx context.Context, resourceName string, resource generated.GenericResource, options *generated.GenericResourcesClientBeginCreateOrUpdateOptions) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { +func (rc *ResourceClientImpl) BeginCreateOrUpdate(ctx context.Context, resourceName string, resource generated.GenericResource, options *generated.GenericResourcesClientBeginCreateOrUpdateOptions) (sdkclients.Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { return rc.inner.BeginCreateOrUpdate(ctx, resourceName, resource, options) } -func (rc *ResourceClientImpl) BeginDelete(ctx context.Context, resourceName string, options *generated.GenericResourcesClientBeginDeleteOptions) (Poller[generated.GenericResourcesClientDeleteResponse], error) { +func (rc *ResourceClientImpl) BeginDelete(ctx context.Context, resourceName string, options *generated.GenericResourcesClientBeginDeleteOptions) (sdkclients.Poller[generated.GenericResourcesClientDeleteResponse], error) { return rc.inner.BeginDelete(ctx, resourceName, options) } -func (rc *ResourceClientImpl) ContinueCreateOperation(ctx context.Context, resumeToken string) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { +func (rc *ResourceClientImpl) ContinueCreateOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { return rc.inner.BeginCreateOrUpdate(ctx, "", generated.GenericResource{}, &generated.GenericResourcesClientBeginCreateOrUpdateOptions{ResumeToken: resumeToken}) } -func (rc *ResourceClientImpl) ContinueDeleteOperation(ctx context.Context, resumeToken string) (Poller[generated.GenericResourcesClientDeleteResponse], error) { +func (rc *ResourceClientImpl) ContinueDeleteOperation(ctx context.Context, resumeToken string) (sdkclients.Poller[generated.GenericResourcesClientDeleteResponse], error) { return rc.inner.BeginDelete(ctx, "", &generated.GenericResourcesClientBeginDeleteOptions{ResumeToken: resumeToken}) } diff --git a/pkg/controller/reconciler/recipe_reconciler.go b/pkg/controller/reconciler/recipe_reconciler.go index 527363d530..7e0803515f 100644 --- a/pkg/controller/reconciler/recipe_reconciler.go +++ b/pkg/controller/reconciler/recipe_reconciler.go @@ -33,6 +33,7 @@ import ( "github.com/radius-project/radius/pkg/cli/clients" "github.com/radius-project/radius/pkg/cli/clients_new/generated" radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" "github.com/radius-project/radius/pkg/ucp/ucplog" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -367,7 +368,7 @@ func (r *RecipeReconciler) reconcileDelete(ctx context.Context, recipe *radappio return ctrl.Result{}, nil } -func (r *RecipeReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Context, recipe *radappiov1alpha3.Recipe) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], Poller[generated.GenericResourcesClientDeleteResponse], error) { +func (r *RecipeReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Context, recipe *radappiov1alpha3.Recipe) (sdkclients.Poller[generated.GenericResourcesClientCreateOrUpdateResponse], sdkclients.Poller[generated.GenericResourcesClientDeleteResponse], error) { logger := ucplog.FromContextOrDiscard(ctx) resourceID := recipe.Status.Scope + "/providers/" + recipe.Spec.Type + "/" + recipe.Name @@ -413,7 +414,7 @@ func (r *RecipeReconciler) startPutOrDeleteOperationIfNeeded(ctx context.Context return nil, nil, nil } -func (r *RecipeReconciler) startDeleteOperationIfNeeded(ctx context.Context, recipe *radappiov1alpha3.Recipe) (Poller[generated.GenericResourcesClientDeleteResponse], error) { +func (r *RecipeReconciler) startDeleteOperationIfNeeded(ctx context.Context, recipe *radappiov1alpha3.Recipe) (sdkclients.Poller[generated.GenericResourcesClientDeleteResponse], error) { logger := ucplog.FromContextOrDiscard(ctx) if recipe.Status.Resource == "" { logger.Info("Resource is already deleted (or was never created).") diff --git a/pkg/controller/reconciler/recipe_reconciler_test.go b/pkg/controller/reconciler/recipe_reconciler_test.go index d403829a45..04ff914046 100644 --- a/pkg/controller/reconciler/recipe_reconciler_test.go +++ b/pkg/controller/reconciler/recipe_reconciler_test.go @@ -21,6 +21,8 @@ import ( "testing" "github.com/radius-project/radius/pkg/cli/clients_new/generated" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" + "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/test/testcontext" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -49,7 +51,7 @@ func SetupRecipeTest(t *testing.T) (*mockRadiusClient, client.Client) { mgr, err := ctrl.NewManager(config, ctrl.Options{ Scheme: scheme, Controller: crconfig.Controller{ - SkipNameValidation: boolPtr(true), + SkipNameValidation: to.Ptr(true), }, // Suppress metrics in tests to avoid conflicts. @@ -90,7 +92,7 @@ func Test_RecipeReconciler_WithoutSecret(t *testing.T) { require.NoError(t, err) // Recipe will be waiting for environment to be created. - createEnvironment(radius, "default") + createEnvironment(radius, "default", "default") // Recipe will be waiting for extender to complete provisioning. status := waitForRecipeStateUpdating(t, client, name, nil) @@ -132,7 +134,7 @@ func Test_RecipeReconciler_ChangeEnvironmentAndApplication(t *testing.T) { require.NoError(t, err) // Recipe will be waiting for environment to be created. - createEnvironment(radius, "default") + createEnvironment(radius, "default", "default") // Recipe will be waiting for extender to complete provisioning. status := waitForRecipeStateUpdating(t, client, name, nil) @@ -146,7 +148,7 @@ func Test_RecipeReconciler_ChangeEnvironmentAndApplication(t *testing.T) { status = waitForRecipeStateReady(t, client, name) require.Equal(t, "/planes/radius/local/resourcegroups/default-recipe-change-envapp/providers/Applications.Core/extenders/test-recipe-change-envapp", status.Resource) - createEnvironment(radius, "new-environment") + createEnvironment(radius, "new-environment", "new-environment") // Now update the recipe to change the environment and application. err = client.Get(ctx, name, recipe) @@ -209,21 +211,21 @@ func Test_RecipeReconciler_FailureRecovery(t *testing.T) { require.NoError(t, err) // Recipe will be waiting for environment to be created. - createEnvironment(radius, "default") + createEnvironment(radius, "default", "default") // Recipe will be waiting for extender to complete provisioning. status := waitForRecipeStateUpdating(t, client, name, nil) // Complete the operation, but make it fail. operation := status.Operation - radius.CompleteOperation(status.Operation.ResumeToken, func(state *operationState) { - state.err = errors.New("oops") + radius.CompleteOperation(status.Operation.ResumeToken, func(state *sdkclients.OperationState) { + state.Err = errors.New("oops") - resource, ok := radius.resources[state.resourceID] + resource, ok := radius.resources[state.ResourceID] require.True(t, ok, "failed to find resource") resource.Properties["provisioningState"] = "Failed" - state.value = generated.GenericResourcesClientCreateOrUpdateResponse{GenericResource: resource} + state.Value = generated.GenericResourcesClientCreateOrUpdateResponse{GenericResource: resource} }) // Recipe should (eventually) start a new provisioning operation @@ -241,10 +243,10 @@ func Test_RecipeReconciler_FailureRecovery(t *testing.T) { // Complete the operation, but make it fail. operation = status.Operation - radius.CompleteOperation(status.Operation.ResumeToken, func(state *operationState) { - state.err = errors.New("oops") + radius.CompleteOperation(status.Operation.ResumeToken, func(state *sdkclients.OperationState) { + state.Err = errors.New("oops") - resource, ok := radius.resources[state.resourceID] + resource, ok := radius.resources[state.ResourceID] require.True(t, ok, "failed to find resource") resource.Properties["provisioningState"] = "Failed" @@ -275,21 +277,21 @@ func Test_RecipeReconciler_WithSecret(t *testing.T) { require.NoError(t, err) // Recipe will be waiting for environment to be created. - createEnvironment(radius, "default") + createEnvironment(radius, "default", "default") // Recipe will be waiting for extender to complete provisioning. status := waitForRecipeStateUpdating(t, client, name, nil) // Update the resource with computed values as part of completing the operation. - radius.CompleteOperation(status.Operation.ResumeToken, func(state *operationState) { - resource, ok := radius.resources[state.resourceID] + radius.CompleteOperation(status.Operation.ResumeToken, func(state *sdkclients.OperationState) { + resource, ok := radius.resources[state.ResourceID] require.True(t, ok, "failed to find resource") resource.Properties["a-value"] = "a" resource.Properties["secrets"] = map[string]string{ "b-secret": "b", } - state.value = generated.GenericResourcesClientCreateOrUpdateResponse{GenericResource: resource} + state.Value = generated.GenericResourcesClientCreateOrUpdateResponse{GenericResource: resource} }) // Recipe will update after operation completes diff --git a/pkg/controller/reconciler/recipe_webhook_test.go b/pkg/controller/reconciler/recipe_webhook_test.go index 7882cafb2b..8bb2c0c857 100644 --- a/pkg/controller/reconciler/recipe_webhook_test.go +++ b/pkg/controller/reconciler/recipe_webhook_test.go @@ -54,7 +54,7 @@ func Test_ValidateRecipe_Type(t *testing.T) { radius, client := setupWebhookTest(t) // Environment is created. - createEnvironment(radius, "default") + createEnvironment(radius, "default", "default") t.Run("test recipe for invalid type", func(t *testing.T) { recipeName := "test-recipe-invalidtype" diff --git a/pkg/controller/reconciler/shared_test.go b/pkg/controller/reconciler/shared_test.go index fee8540702..6990f7adbe 100644 --- a/pkg/controller/reconciler/shared_test.go +++ b/pkg/controller/reconciler/shared_test.go @@ -43,8 +43,8 @@ const ( recipeTestControllerDelayInterval = time.Millisecond * 100 ) -func createEnvironment(radius *mockRadiusClient, name string) { - id := fmt.Sprintf("/planes/radius/local/resourceGroups/%s/providers/Applications.Core/environments/%s", name, name) +func createEnvironment(radius *mockRadiusClient, resourceGroup, name string) { + id := fmt.Sprintf("/planes/radius/local/resourceGroups/%s/providers/Applications.Core/environments/%s", resourceGroup, name) radius.Update(func() { radius.environments[id] = v20231001preview.EnvironmentResource{ ID: to.Ptr(id), @@ -188,6 +188,28 @@ func makeDeployment(name types.NamespacedName) *appsv1.Deployment { } } -func boolPtr(b bool) *bool { - return &b +func makeDeploymentTemplate(name types.NamespacedName, template, providerConfig string, parameters map[string]string) *radappiov1alpha3.DeploymentTemplate { + return &radappiov1alpha3.DeploymentTemplate{ + ObjectMeta: ctrl.ObjectMeta{ + Namespace: name.Namespace, + Name: name.Name, + }, + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: template, + ProviderConfig: providerConfig, + Parameters: parameters, + }, + } +} + +func makeDeploymentResource(name types.NamespacedName, id string) *radappiov1alpha3.DeploymentResource { + return &radappiov1alpha3.DeploymentResource{ + ObjectMeta: ctrl.ObjectMeta{ + Namespace: name.Namespace, + Name: name.Name, + }, + Spec: radappiov1alpha3.DeploymentResourceSpec{ + Id: id, + }, + } } diff --git a/pkg/controller/reconciler/testdata/deploymenttemplate-outputresources-1.json b/pkg/controller/reconciler/testdata/deploymenttemplate-outputresources-1.json new file mode 100644 index 0000000000..a556af9256 --- /dev/null +++ b/pkg/controller/reconciler/testdata/deploymenttemplate-outputresources-1.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.33.13.18514", + "templateHash": "2963919247542208354" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "outputResourcesEnvironment": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "output-resources-environment", + "properties": { + "compute": { + "kind": "kubernetes", + "namespace": "default" + } + } + } + }, + "outputResourcesApplication": { + "import": "Radius", + "type": "Applications.Core/applications@2023-10-01-preview", + "properties": { + "name": "output-resources-application", + "properties": { + "environment": "[reference('outputResourcesEnvironment').id]" + } + }, + "dependsOn": ["outputResourcesEnvironment"] + }, + "outputResourcesContainer": { + "import": "Radius", + "type": "Applications.Core/containers@2023-10-01-preview", + "properties": { + "name": "output-resources-container", + "properties": { + "application": "[reference('outputResourcesApplication').id]", + "container": { + "image": "nginx" + } + } + }, + "dependsOn": ["outputResourcesApplication"] + } + } +} diff --git a/pkg/controller/reconciler/testdata/deploymenttemplate-outputresources-2.json b/pkg/controller/reconciler/testdata/deploymenttemplate-outputresources-2.json new file mode 100644 index 0000000000..7fc4fbf488 --- /dev/null +++ b/pkg/controller/reconciler/testdata/deploymenttemplate-outputresources-2.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.33.13.18514", + "templateHash": "2963919247542208354" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "outputResourcesEnvironment": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "output-resources-environment", + "properties": { + "compute": { + "kind": "kubernetes", + "namespace": "default" + } + } + } + }, + "outputResourcesApplication": { + "import": "Radius", + "type": "Applications.Core/applications@2023-10-01-preview", + "properties": { + "name": "output-resources-application", + "properties": { + "environment": "[reference('outputResourcesEnvironment').id]" + } + }, + "dependsOn": ["outputResourcesEnvironment"] + } + } +} diff --git a/pkg/controller/reconciler/testdata/deploymenttemplate-update-1.json b/pkg/controller/reconciler/testdata/deploymenttemplate-update-1.json new file mode 100644 index 0000000000..b2330d3d44 --- /dev/null +++ b/pkg/controller/reconciler/testdata/deploymenttemplate-update-1.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.31.92.45157", + "templateHash": "17470211592317605856" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "deploymenttemplate-update-env": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "deploymenttemplate-update-env", + "location": "global", + "properties": { + "compute": { + "kind": "kubernetes", + "resourceId": "self", + "namespace": "default" + } + } + } + } + } +} diff --git a/pkg/controller/reconciler/testdata/deploymenttemplate-update-2.json b/pkg/controller/reconciler/testdata/deploymenttemplate-update-2.json new file mode 100644 index 0000000000..0587001980 --- /dev/null +++ b/pkg/controller/reconciler/testdata/deploymenttemplate-update-2.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.31.92.45157", + "templateHash": "17470211592317605856" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "deploymenttemplate-update-env": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "deploymenttemplate-update-env", + "location": "global", + "properties": { + "compute": { + "kind": "kubernetes", + "resourceId": "self", + "namespace": "notdefault" + } + } + } + } + } +} diff --git a/pkg/controller/reconciler/testdata/deploymenttemplate-withresources.json b/pkg/controller/reconciler/testdata/deploymenttemplate-withresources.json new file mode 100644 index 0000000000..4a0a325347 --- /dev/null +++ b/pkg/controller/reconciler/testdata/deploymenttemplate-withresources.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.31.92.45157", + "templateHash": "17470211592317605856" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "deploymenttemplate-withresources-env": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "deploymenttemplate-withresources-env", + "location": "global", + "properties": { + "compute": { + "kind": "kubernetes", + "resourceId": "self", + "namespace": "default" + } + } + } + } + } +} diff --git a/pkg/controller/reconciler/util.go b/pkg/controller/reconciler/util.go index 0ce88e7cfa..f85254f774 100644 --- a/pkg/controller/reconciler/util.go +++ b/pkg/controller/reconciler/util.go @@ -25,6 +25,7 @@ import ( "github.com/radius-project/radius/pkg/cli/clients" "github.com/radius-project/radius/pkg/cli/clients_new/generated" corerpv20231001preview "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" "github.com/radius-project/radius/pkg/to" ucpv20231001preview "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/pkg/ucp/resources" @@ -151,7 +152,7 @@ func createApplicationIfNotExists(ctx context.Context, radius RadiusClient, envi return nil } -func deleteResource(ctx context.Context, radius RadiusClient, resourceID string) (Poller[generated.GenericResourcesClientDeleteResponse], error) { +func deleteResource(ctx context.Context, radius RadiusClient, resourceID string) (sdkclients.Poller[generated.GenericResourcesClientDeleteResponse], error) { id, err := resources.Parse(resourceID) if err != nil { return nil, err @@ -178,7 +179,7 @@ func deleteResource(ctx context.Context, radius RadiusClient, resourceID string) return nil, nil } -func createOrUpdateResource(ctx context.Context, radius RadiusClient, resourceID string, properties map[string]any) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { +func createOrUpdateResource(ctx context.Context, radius RadiusClient, resourceID string, properties map[string]any) (sdkclients.Poller[generated.GenericResourcesClientCreateOrUpdateResponse], error) { id, err := resources.Parse(resourceID) if err != nil { return nil, err @@ -222,7 +223,7 @@ func fetchResource(ctx context.Context, radius RadiusClient, resourceID string) return radius.Resources(id.RootScope(), id.Type()).Get(ctx, id.Name()) } -func deleteContainer(ctx context.Context, radius RadiusClient, containerID string) (Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { +func deleteContainer(ctx context.Context, radius RadiusClient, containerID string) (sdkclients.Poller[corerpv20231001preview.ContainersClientDeleteResponse], error) { id, err := resources.Parse(containerID) if err != nil { return nil, err @@ -249,7 +250,7 @@ func deleteContainer(ctx context.Context, radius RadiusClient, containerID strin return nil, nil } -func createOrUpdateContainer(ctx context.Context, radius RadiusClient, containerID string, properties *corerpv20231001preview.ContainerProperties) (Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { +func createOrUpdateContainer(ctx context.Context, radius RadiusClient, containerID string, properties *corerpv20231001preview.ContainerProperties) (sdkclients.Poller[corerpv20231001preview.ContainersClientCreateOrUpdateResponse], error) { id, err := resources.Parse(containerID) if err != nil { return nil, err @@ -280,3 +281,22 @@ func createOrUpdateContainer(ctx context.Context, radius RadiusClient, container return nil, nil } + +func generateDeploymentResourceName(resourceId string) (string, error) { + id, err := resources.ParseResource(resourceId) + if err != nil { + return "", err + } + + return id.Name(), nil +} + +func convertToARMJSONParameters(parameters map[string]string) map[string]map[string]string { + armJSONParameters := make(map[string]map[string]string, len(parameters)) + for key, value := range parameters { + armJSONParameters[key] = map[string]string{ + "value": value, + } + } + return armJSONParameters +} diff --git a/pkg/controller/reconciler/util_test.go b/pkg/controller/reconciler/util_test.go new file mode 100644 index 0000000000..546d27c416 --- /dev/null +++ b/pkg/controller/reconciler/util_test.go @@ -0,0 +1,88 @@ +package reconciler + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateDeploymentResourceName(t *testing.T) { + tests := []struct { + name string + resourceId string + want string + wantErr bool + }{ + { + name: "valid resource ID", + resourceId: "/subscriptions/123/resourceGroups/myResourceGroup/providers/Microsoft.Web/sites/mySite", + want: "mySite", + wantErr: false, + }, + { + name: "invalid resource ID", + resourceId: "invalidResourceId", + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := generateDeploymentResourceName(tt.resourceId) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} + +func TestConvertToARMJSONParameters(t *testing.T) { + tests := []struct { + name string + parameters map[string]string + want map[string]map[string]string + }{ + { + name: "single parameter", + parameters: map[string]string{ + "param1": "value1", + }, + want: map[string]map[string]string{ + "param1": { + "value": "value1", + }, + }, + }, + { + name: "multiple parameters", + parameters: map[string]string{ + "param1": "value1", + "param2": "value2", + }, + want: map[string]map[string]string{ + "param1": { + "value": "value1", + }, + "param2": { + "value": "value2", + }, + }, + }, + { + name: "empty parameters", + parameters: map[string]string{}, + want: map[string]map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertToARMJSONParameters(tt.parameters) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/controller/service.go b/pkg/controller/service.go index 422aee16d5..f6d5f5093b 100644 --- a/pkg/controller/service.go +++ b/pkg/controller/service.go @@ -21,9 +21,12 @@ import ( "fmt" "github.com/radius-project/radius/pkg/armrpc/hostoptions" + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/components/hosting" radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" "github.com/radius-project/radius/pkg/controller/reconciler" + "github.com/radius-project/radius/pkg/sdk" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" "github.com/radius-project/radius/pkg/ucp/ucplog" "k8s.io/apimachinery/pkg/runtime" @@ -93,7 +96,7 @@ func (s *Service) Run(ctx context.Context) error { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), EventRecorder: mgr.GetEventRecorderFor("recipe-controller"), - Radius: reconciler.NewClient(s.Options.UCPConnection), + Radius: reconciler.NewRadiusClient(s.Options.UCPConnection), }).SetupWithManager(mgr) if err != nil { return fmt.Errorf("failed to setup %s controller: %w", "Recipe", err) @@ -102,12 +105,41 @@ func (s *Service) Run(ctx context.Context) error { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), EventRecorder: mgr.GetEventRecorderFor("radius-deployment-controller"), - Radius: reconciler.NewClient(s.Options.UCPConnection), + Radius: reconciler.NewRadiusClient(s.Options.UCPConnection), }).SetupWithManager(mgr) if err != nil { return fmt.Errorf("failed to setup %s controller: %w", "Deployment", err) } + resourceDeploymentsClient, err := sdkclients.NewResourceDeploymentsClient(&sdkclients.Options{ + Cred: &aztoken.AnonymousCredential{}, + BaseURI: s.Options.UCPConnection.Endpoint(), + ARMClientOptions: sdk.NewClientOptions(s.Options.UCPConnection), + }) + if err != nil { + return fmt.Errorf("failed to create resource deployments client: %w", err) + } + err = (&reconciler.DeploymentTemplateReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventRecorder: mgr.GetEventRecorderFor("deploymenttemplate-controller"), + Radius: reconciler.NewRadiusClient(s.Options.UCPConnection), + ResourceDeploymentsClient: resourceDeploymentsClient, + }).SetupWithManager(mgr) + if err != nil { + return fmt.Errorf("failed to setup %s controller: %w", "DeploymentTemplate", err) + } + err = (&reconciler.DeploymentResourceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventRecorder: mgr.GetEventRecorderFor("deploymentresource-controller"), + Radius: reconciler.NewRadiusClient(s.Options.UCPConnection), + ResourceDeploymentsClient: resourceDeploymentsClient, + }).SetupWithManager(mgr) + if err != nil { + return fmt.Errorf("failed to setup %s controller: %w", "DeploymentResource", err) + } + if s.TLSCertDir == "" { logger.Info("Webhooks will be skipped. TLS certificates not present.") } else { diff --git a/pkg/recipes/controllerconfig/config.go b/pkg/recipes/controllerconfig/config.go index 6ae1e3a82d..dbdf1afe59 100644 --- a/pkg/recipes/controllerconfig/config.go +++ b/pkg/recipes/controllerconfig/config.go @@ -44,7 +44,7 @@ type RecipeControllerConfig struct { ConfigLoader configloader.ConfigurationLoader // DeploymentEngineClient is the client for interacting with the deployment engine. - DeploymentEngineClient *clients.ResourceDeploymentsClient + DeploymentEngineClient clients.ResourceDeploymentsClient // Engine is the engine for executing recipes. Engine engine.Engine diff --git a/pkg/recipes/driver/bicep.go b/pkg/recipes/driver/bicep.go index 745d8b4d1c..bbf8310479 100644 --- a/pkg/recipes/driver/bicep.go +++ b/pkg/recipes/driver/bicep.go @@ -24,7 +24,6 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/go-logr/logr" "golang.org/x/sync/errgroup" @@ -56,7 +55,7 @@ const ( var _ Driver = (*bicepDriver)(nil) // NewBicepDriver creates a new bicep driver instance with the given ARM client options, deployment client, resource client, and options. -func NewBicepDriver(armOptions *arm.ClientOptions, deploymentClient *clients.ResourceDeploymentsClient, client processors.ResourceClient, options BicepOptions) Driver { +func NewBicepDriver(armOptions *arm.ClientOptions, deploymentClient clients.ResourceDeploymentsClient, client processors.ResourceClient, options BicepOptions) Driver { return &bicepDriver{ ArmClientOptions: armOptions, DeploymentClient: deploymentClient, @@ -72,7 +71,7 @@ type BicepOptions struct { type bicepDriver struct { ArmClientOptions *arm.ClientOptions - DeploymentClient *clients.ResourceDeploymentsClient + DeploymentClient clients.ResourceDeploymentsClient ResourceClient processors.ResourceClient options BicepOptions @@ -160,7 +159,7 @@ func (d *bicepDriver) Execute(ctx context.Context, opts ExecuteOptions) (*recipe return nil, recipes.NewRecipeError(recipes.RecipeDeploymentFailed, fmt.Sprintf("failed to deploy recipe %s of type %s", opts.BaseOptions.Recipe.Name, opts.BaseOptions.Definition.ResourceType), recipes_util.ExecutionError, recipes.GetErrorDetails(err)) } - resp, err := poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{Frequency: pollFrequency}) + resp, err := poller.PollUntilDone(ctx, &clients.PollUntilDoneOptions{Frequency: pollFrequency}) if err != nil { return nil, recipes.NewRecipeError(recipes.RecipeDeploymentFailed, fmt.Sprintf("failed to deploy recipe %s of type %s", opts.BaseOptions.Recipe.Name, opts.BaseOptions.Definition.ResourceType), recipes_util.ExecutionError, recipes.GetErrorDetails(err)) } diff --git a/pkg/sdk/clients/mock_resourcedeploymentsclient.go b/pkg/sdk/clients/mock_resourcedeploymentsclient.go new file mode 100644 index 0000000000..c02a8b2a0b --- /dev/null +++ b/pkg/sdk/clients/mock_resourcedeploymentsclient.go @@ -0,0 +1,198 @@ +/* +Copyright 2024 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 clients + +import ( + "context" + "net/http" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/google/uuid" +) + +// This file contains mocks for the ResourceDeploymentsClient interface. + +func NewMockResourceDeploymentsClient() *MockResourceDeploymentsClient { + return &MockResourceDeploymentsClient{ + resourceDeployments: map[string]*ClientCreateOrUpdateResponse{}, + operations: map[string]*OperationState{}, + + lock: &sync.Mutex{}, + } +} + +var _ ResourceDeploymentsClient = (*MockResourceDeploymentsClient)(nil) + +type MockResourceDeploymentsClient struct { + resourceDeployments map[string]*ClientCreateOrUpdateResponse + operations map[string]*OperationState + + lock *sync.Mutex +} + +func (rdc *MockResourceDeploymentsClient) CompleteOperation(operationID string, update func(state *OperationState)) { + rdc.lock.Lock() + defer rdc.lock.Unlock() + + state, ok := rdc.operations[operationID] + if !ok { + panic("operation not found: " + operationID) + } + + if update != nil { + update(state) + } + + state.Complete = true +} + +func (rdc *MockResourceDeploymentsClient) CreateOrUpdate(ctx context.Context, parameters Deployment, resourceID, apiVersion string) (Poller[ClientCreateOrUpdateResponse], error) { + rdc.lock.Lock() + defer rdc.lock.Unlock() + + value := ClientCreateOrUpdateResponse{ + DeploymentExtended: armresources.DeploymentExtended{ + ID: &resourceID, + Properties: &armresources.DeploymentPropertiesExtended{}, + }, + } + state := &OperationState{ + Kind: http.MethodPut, + ResourceID: resourceID, + Value: value, + } + + operationID := uuid.New().String() + rdc.resourceDeployments[resourceID] = &value + rdc.operations[operationID] = state + + return &MockResourceDeploymentsClientPoller[ClientCreateOrUpdateResponse]{ + mock: rdc, + operationID: operationID, + state: state, + }, nil +} + +func (rdc *MockResourceDeploymentsClient) ContinueCreateOperation(ctx context.Context, resumeToken string) (Poller[ClientCreateOrUpdateResponse], error) { + rdc.lock.Lock() + defer rdc.lock.Unlock() + + state, ok := rdc.operations[resumeToken] + if !ok { + return nil, &azcore.ResponseError{StatusCode: http.StatusNotFound} + } + + return &MockResourceDeploymentsClientPoller[ClientCreateOrUpdateResponse]{ + operationID: resumeToken, + mock: rdc, + state: state, + }, nil +} + +func (rdc *MockResourceDeploymentsClient) Delete(ctx context.Context, resourceID, apiVersion string) (Poller[ClientDeleteResponse], error) { + rdc.lock.Lock() + defer rdc.lock.Unlock() + + state := &OperationState{ + Kind: http.MethodDelete, + ResourceID: resourceID, + Value: ClientDeleteResponse{ + DeploymentExtended: armresources.DeploymentExtended{ + ID: &resourceID, + Properties: &armresources.DeploymentPropertiesExtended{}, + }, + }, + } + + operationID := uuid.New().String() + rdc.operations[operationID] = state + + return &MockResourceDeploymentsClientPoller[ClientDeleteResponse]{ + mock: rdc, + operationID: operationID, + state: state, + }, nil +} + +func (rdc *MockResourceDeploymentsClient) ContinueDeleteOperation(ctx context.Context, resumeToken string) (Poller[ClientDeleteResponse], error) { + rdc.lock.Lock() + defer rdc.lock.Unlock() + + state, ok := rdc.operations[resumeToken] + if !ok { + return nil, &azcore.ResponseError{StatusCode: http.StatusNotFound} + } + + return &MockResourceDeploymentsClientPoller[ClientDeleteResponse]{ + operationID: resumeToken, + mock: rdc, + state: state, + }, nil +} + +func (rdc *MockResourceDeploymentsClient) GetResource(resourceID string) (*ClientCreateOrUpdateResponse, bool) { + resource, ok := rdc.resourceDeployments[resourceID] + + return resource, ok +} + +type MockResourceDeploymentsClientPoller[T any] struct { + operationID string + mock *MockResourceDeploymentsClient + state *OperationState +} + +var _ Poller[ClientCreateOrUpdateResponse] = (*MockResourceDeploymentsClientPoller[ClientCreateOrUpdateResponse])(nil) + +func (mp *MockResourceDeploymentsClientPoller[T]) Done() bool { + mp.mock.lock.Lock() + defer mp.mock.lock.Unlock() + + return mp.state.Complete // Status updates are delivered via the Poll function. +} + +func (mp *MockResourceDeploymentsClientPoller[T]) Poll(ctx context.Context) (*http.Response, error) { + mp.mock.lock.Lock() + defer mp.mock.lock.Unlock() + + // NOTE: this is ok because our code ignores the actual result. + mp.state = mp.mock.operations[mp.operationID] + return nil, nil +} + +func (mp *MockResourceDeploymentsClientPoller[T]) Result(ctx context.Context) (T, error) { + mp.mock.lock.Lock() + defer mp.mock.lock.Unlock() + + if mp.state.Complete && mp.state.Err != nil { + return mp.state.Value.(T), mp.state.Err + } else if mp.state.Complete { + return mp.state.Value.(T), nil + } + + panic("operation not done") +} + +func (mp *MockResourceDeploymentsClientPoller[T]) ResumeToken() (string, error) { + return mp.operationID, nil +} + +func (mp *MockResourceDeploymentsClientPoller[T]) PollUntilDone(ctx context.Context, options *PollUntilDoneOptions) (T, error) { + return mp.Result(ctx) +} diff --git a/pkg/sdk/clients/poller.go b/pkg/sdk/clients/poller.go new file mode 100644 index 0000000000..2f19be1a3d --- /dev/null +++ b/pkg/sdk/clients/poller.go @@ -0,0 +1,48 @@ +/* +Copyright 2024 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 clients + +import ( + "context" + "net/http" + + azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" +) + +// Poller is an interface for polling an operation. +// This uses the same functions of the Poller struct from the Azure SDK for Go. +// We use this interface to allow mocking the Poller struct in tests. +type Poller[T any] interface { + Done() bool + Poll(ctx context.Context) (*http.Response, error) + PollUntilDone(ctx context.Context, options *PollUntilDoneOptions) (T, error) + Result(ctx context.Context) (T, error) + ResumeToken() (string, error) +} + +type PollUntilDoneOptions = azcoreruntime.PollUntilDoneOptions + +// OperationState represents the state of an operation. +// This is a simplified version of the real OperationState that we use in testing. +type OperationState struct { + Complete bool + Value any + // Ideally we'd use azcore.ResponseError here, but it's tricky to set up in tests. + Err error + ResourceID string + Kind string +} diff --git a/pkg/sdk/clients/providerconfig.go b/pkg/sdk/clients/providerconfig.go index 8d8a810aa0..aba2eb1e43 100644 --- a/pkg/sdk/clients/providerconfig.go +++ b/pkg/sdk/clients/providerconfig.go @@ -16,6 +16,11 @@ limitations under the License. package clients +import ( + "encoding/json" + "fmt" +) + const ( // ProviderTypeAzure is used to specify the provider configuration for Azure resources. ProviderTypeAzure = "AzureResourceManager" @@ -33,18 +38,71 @@ const ( func NewDefaultProviderConfig(resourceGroup string) ProviderConfig { config := ProviderConfig{ Deployments: &Deployments{ - Type: "Microsoft.Resources", + Type: ProviderTypeDeployments, Value: Value{ - Scope: "/planes/radius/local/resourceGroups/" + resourceGroup, + Scope: constructRadiusDeploymentScope(resourceGroup), }, }, Radius: &Radius{ - Type: "Radius", + Type: ProviderTypeRadius, Value: Value{ - Scope: "/planes/radius/local/resourceGroups/" + resourceGroup, + Scope: constructRadiusDeploymentScope(resourceGroup), }, }, } return config } + +// GenerateProviderConfig generates a ProviderConfig object based on the given scopes. +func GenerateProviderConfig(resourceGroup, awsScope, azureScope string) ProviderConfig { + providerConfig := ProviderConfig{} + if awsScope != "" { + providerConfig.AWS = &AWS{ + Type: ProviderTypeAWS, + Value: Value{ + Scope: awsScope, + }, + } + } + if azureScope != "" { + providerConfig.Az = &Az{ + Type: ProviderTypeAzure, + Value: Value{ + Scope: azureScope, + }, + } + } + if resourceGroup != "" { + providerConfig.Radius = &Radius{ + Type: ProviderTypeRadius, + Value: Value{ + Scope: constructRadiusDeploymentScope(resourceGroup), + }, + } + providerConfig.Deployments = &Deployments{ + Type: ProviderTypeDeployments, + Value: Value{ + Scope: constructRadiusDeploymentScope(resourceGroup), + }, + } + } + + return providerConfig +} + +// constructRadiusDeploymentScope constructs the scope for Radius deployments. +func constructRadiusDeploymentScope(group string) string { + return fmt.Sprintf("/planes/radius/local/resourceGroups/%s", group) +} + +// String returns the JSON representation of the ProviderConfig object +// in a string format. +func (p ProviderConfig) String() (string, error) { + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return "", err + } + + return string(b), nil +} diff --git a/pkg/sdk/clients/resourcedeploymentsclient.go b/pkg/sdk/clients/resourcedeploymentsclient.go index 11a3a2b72a..dea38746b3 100644 --- a/pkg/sdk/clients/resourcedeploymentsclient.go +++ b/pkg/sdk/clients/resourcedeploymentsclient.go @@ -45,7 +45,7 @@ type DeploymentProperties struct { Template any `json:"template,omitempty"` // TemplateLink - The URI of the template. Use either the templateLink property or the template property, but not both. TemplateLink *armresources.TemplateLink `json:"templateLink,omitempty"` - //ProviderConfig specifies the scope for resources + // ProviderConfig specifies the scope for resources ProviderConfig any `json:"providerconfig,omitempty"` // Parameters - Name and value pairs that define the deployment parameters for the template. You use this element when you want to provide the parameter values directly in the request rather than link to an existing parameter file. Use either the parametersLink property or the parameters property, but not both. It can be a JObject or a well formed JSON string. Parameters any `json:"parameters,omitempty"` @@ -94,15 +94,24 @@ type ProviderConfig struct { // ResourceDeploymentsClient is a deployments client for Azure Resource Manager. // It is used by both Azure and UCP clients. -type ResourceDeploymentsClient struct { +type ResourceDeploymentsClient interface { + CreateOrUpdate(ctx context.Context, parameters Deployment, resourceID, apiVersion string) (Poller[ClientCreateOrUpdateResponse], error) + ContinueCreateOperation(ctx context.Context, resumeToken string) (Poller[ClientCreateOrUpdateResponse], error) + Delete(ctx context.Context, resourceID, apiVersion string) (Poller[ClientDeleteResponse], error) + ContinueDeleteOperation(ctx context.Context, resumeToken string) (Poller[ClientDeleteResponse], error) +} + +type ResourceDeploymentsClientImpl struct { client *armresources.Client pipeline *runtime.Pipeline baseURI string } +var _ ResourceDeploymentsClient = (*ResourceDeploymentsClientImpl)(nil) + // NewResourceDeploymentsClient creates a new ResourceDeploymentsClient with the provided options and returns an error if // the options are invalid. -func NewResourceDeploymentsClient(options *Options) (*ResourceDeploymentsClient, error) { +func NewResourceDeploymentsClient(options *Options) (ResourceDeploymentsClient, error) { if options.BaseURI == "" { return nil, errors.New("baseURI cannot be empty") } @@ -118,7 +127,7 @@ func NewResourceDeploymentsClient(options *Options) (*ResourceDeploymentsClient, return nil, err } - return &ResourceDeploymentsClient{ + return &ResourceDeploymentsClientImpl{ client: client, pipeline: &pipeline, baseURI: options.BaseURI, @@ -130,9 +139,14 @@ type ClientCreateOrUpdateResponse struct { armresources.DeploymentExtended } +// ClientDeleteResponse contains the response from method Client.Delete. +type ClientDeleteResponse struct { + armresources.DeploymentExtended +} + // CreateOrUpdate creates a request to create or update a deployment and returns a poller to // track the progress of the operation. -func (client *ResourceDeploymentsClient) CreateOrUpdate(ctx context.Context, parameters Deployment, resourceID, apiVersion string) (*runtime.Poller[ClientCreateOrUpdateResponse], error) { +func (client *ResourceDeploymentsClientImpl) CreateOrUpdate(ctx context.Context, parameters Deployment, resourceID, apiVersion string) (Poller[ClientCreateOrUpdateResponse], error) { if !strings.HasPrefix(resourceID, "/") { return nil, fmt.Errorf("error creating or updating a deployment: resourceID must start with a slash") } @@ -159,7 +173,7 @@ func (client *ResourceDeploymentsClient) CreateOrUpdate(ctx context.Context, par } // createOrUpdateCreateRequest creates the CreateOrUpdate request. -func (client *ResourceDeploymentsClient) createOrUpdateCreateRequest(ctx context.Context, resourceID, apiVersion string, parameters Deployment) (*policy.Request, error) { +func (client *ResourceDeploymentsClientImpl) createOrUpdateCreateRequest(ctx context.Context, resourceID, apiVersion string, parameters Deployment) (*policy.Request, error) { if resourceID == "" { return nil, errors.New("resourceID cannot be empty") } @@ -175,3 +189,59 @@ func (client *ResourceDeploymentsClient) createOrUpdateCreateRequest(ctx context req.Raw().Header["Accept"] = []string{"application/json"} return req, runtime.MarshalAsJSON(req, parameters) } + +// ContinueCreateOperation continues a create operation given a resume token. +func (client *ResourceDeploymentsClientImpl) ContinueCreateOperation(ctx context.Context, resumeToken string) (Poller[ClientCreateOrUpdateResponse], error) { + return runtime.NewPollerFromResumeToken[ClientCreateOrUpdateResponse](resumeToken, *client.pipeline, nil) +} + +// Delete creates a request to delete a resource and returns a poller to +// track the progress of the operation. +func (client *ResourceDeploymentsClientImpl) Delete(ctx context.Context, resourceID, apiVersion string) (Poller[ClientDeleteResponse], error) { + if !strings.HasPrefix(resourceID, "/") { + return nil, fmt.Errorf("error creating or updating a deployment: resourceID must start with a slash") + } + + _, err := resources.ParseResource(resourceID) + if err != nil { + return nil, fmt.Errorf("invalid resourceID: %v", resourceID) + } + + req, err := client.deleteCreateRequest(ctx, resourceID, apiVersion) + if err != nil { + return nil, err + } + + resp, err := client.pipeline.Do(req) + if err != nil { + return nil, err + } + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent, http.StatusAccepted, http.StatusNotFound) { + return nil, runtime.NewResponseError(resp) + } + + return runtime.NewPoller[ClientDeleteResponse](resp, *client.pipeline, nil) +} + +// deleteCreateRequest creates the Delete request. +func (client *ResourceDeploymentsClientImpl) deleteCreateRequest(ctx context.Context, resourceID, apiVersion string) (*policy.Request, error) { + if resourceID == "" { + return nil, errors.New("resourceID cannot be empty") + } + + urlPath := DeploymentEngineURL(client.baseURI, resourceID) + req, err := runtime.NewRequest(ctx, http.MethodDelete, urlPath) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", apiVersion) + req.Raw().URL.RawQuery = reqQP.Encode() + req.Raw().Header["Accept"] = []string{"application/json"} + return req, nil +} + +// ContinueCreateOperation continues a create operation given a resume token. +func (client *ResourceDeploymentsClientImpl) ContinueDeleteOperation(ctx context.Context, resumeToken string) (Poller[ClientDeleteResponse], error) { + return runtime.NewPollerFromResumeToken[ClientDeleteResponse](resumeToken, *client.pipeline, nil) +} diff --git a/test/functional-portable/kubernetes/noncloud/deploymenttemplate_test.go b/test/functional-portable/kubernetes/noncloud/deploymenttemplate_test.go new file mode 100644 index 0000000000..9e90d92990 --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/deploymenttemplate_test.go @@ -0,0 +1,339 @@ +/* +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 kubernetes_test + +import ( + "context" + "fmt" + "os" + "path" + "strings" + "testing" + "time" + + aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" + "github.com/radius-project/radius/pkg/cli/clients_new/generated" + radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" + "github.com/radius-project/radius/pkg/controller/reconciler" + "github.com/radius-project/radius/pkg/sdk" + sdkclients "github.com/radius-project/radius/pkg/sdk/clients" + "github.com/radius-project/radius/test/rp" + "github.com/radius-project/radius/test/testcontext" + "github.com/radius-project/radius/test/testutil" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/tools/cache" + watchtools "k8s.io/client-go/tools/watch" + controller_runtime "sigs.k8s.io/controller-runtime/pkg/client" +) + +func Test_DeploymentTemplate_Env(t *testing.T) { + ctx := testcontext.New(t) + opts := rp.NewRPTestOptions(t) + + name := "dt-env" + namespace := "dt-env-ns" + templateFilePath := path.Join("testdata", "env", "env.json") + parameters := []string{ + fmt.Sprintf("name=%s", name), + fmt.Sprintf("namespace=%s", namespace), + } + + providerConfig, err := sdkclients.NewDefaultProviderConfig(name).String() + require.NoError(t, err) + + parametersMap := createParametersMap(parameters) + + template, err := os.ReadFile(templateFilePath) + require.NoError(t, err) + + // Create the namespace, if it already exists we can ignore the error. + _, err = opts.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, metav1.CreateOptions{}) + require.NoError(t, controller_runtime.IgnoreAlreadyExists(err)) + + deploymentTemplate := makeDeploymentTemplate(types.NamespacedName{Name: name, Namespace: namespace}, string(template), providerConfig, parametersMap) + + t.Run("Create DeploymentTemplate", func(t *testing.T) { + t.Log("Creating DeploymentTemplate") + err = opts.Client.Create(ctx, deploymentTemplate) + require.NoError(t, err) + }) + + t.Run("Check DeploymentTemplate status", func(t *testing.T) { + ctx, cancel := testcontext.NewWithCancel(t) + defer cancel() + + // Get resource version + err = opts.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, deploymentTemplate) + require.NoError(t, err) + + t.Log("Waiting for DeploymentTemplate ready") + deploymentTemplate, err := waitForDeploymentTemplateReady(t, ctx, types.NamespacedName{Name: name, Namespace: namespace}, opts.Client, deploymentTemplate.ResourceVersion) + require.NoError(t, err) + + scope, err := reconciler.ParseDeploymentScopeFromProviderConfig(deploymentTemplate.Spec.ProviderConfig) + require.NoError(t, err) + + expectedResources := [][]string{ + {"Applications.Core/environments", fmt.Sprintf("%s-env", name)}, + } + + assertExpectedResourcesExist(t, ctx, scope, expectedResources, opts.Connection) + }) + + t.Run("Delete DeploymentTemplate", func(t *testing.T) { + t.Log("Deleting DeploymentTemplate") + err = opts.Client.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + require.Eventually(t, func() bool { + err = opts.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, deploymentTemplate) + return apierrors.IsNotFound(err) + }, time.Second*60, time.Second*5, "waiting for deploymentTemplate to be deleted") + }) +} + +func Test_DeploymentTemplate_Module(t *testing.T) { + ctx := testcontext.New(t) + opts := rp.NewRPTestOptions(t) + + name := "dt-module" + namespace := "dt-module-ns" + templateFilePath := path.Join("testdata", "module", "module.json") + parameters := []string{ + fmt.Sprintf("name=%s", name), + fmt.Sprintf("namespace=%s", namespace), + } + + providerConfig, err := sdkclients.NewDefaultProviderConfig(name).String() + require.NoError(t, err) + + parametersMap := createParametersMap(parameters) + + template, err := os.ReadFile(templateFilePath) + require.NoError(t, err) + + // Create the namespace, if it already exists we can ignore the error. + _, err = opts.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, metav1.CreateOptions{}) + require.NoError(t, controller_runtime.IgnoreAlreadyExists(err)) + + deploymentTemplate := makeDeploymentTemplate(types.NamespacedName{Name: name, Namespace: namespace}, string(template), providerConfig, parametersMap) + + t.Run("Create DeploymentTemplate", func(t *testing.T) { + t.Log("Creating DeploymentTemplate") + err = opts.Client.Create(ctx, deploymentTemplate) + require.NoError(t, err) + }) + + t.Run("Check DeploymentTemplate status", func(t *testing.T) { + ctx, cancel := testcontext.NewWithCancel(t) + defer cancel() + + // Get resource version + err = opts.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, deploymentTemplate) + require.NoError(t, err) + + t.Log("Waiting for DeploymentTemplate ready") + deploymentTemplate, err := waitForDeploymentTemplateReady(t, ctx, types.NamespacedName{Name: name, Namespace: namespace}, opts.Client, deploymentTemplate.ResourceVersion) + require.NoError(t, err) + + scope, err := reconciler.ParseDeploymentScopeFromProviderConfig(deploymentTemplate.Spec.ProviderConfig) + require.NoError(t, err) + + expectedResources := [][]string{ + {"Applications.Core/environments", fmt.Sprintf("%s-env", name)}, + {"Applications.Core/applications", fmt.Sprintf("%s-app", name)}, + } + + assertExpectedResourcesExist(t, ctx, scope, expectedResources, opts.Connection) + }) + + t.Run("Delete DeploymentTemplate", func(t *testing.T) { + t.Log("Deleting DeploymentTemplate") + err = opts.Client.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + require.Eventually(t, func() bool { + err = opts.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, deploymentTemplate) + return apierrors.IsNotFound(err) + }, time.Second*60, time.Second*5, "waiting for deploymentTemplate to be deleted") + }) +} + +func Test_DeploymentTemplate_Recipe(t *testing.T) { + ctx := testcontext.New(t) + opts := rp.NewRPTestOptions(t) + + name := "dt-recipe" + namespace := "dt-recipe-ns" + templateFilePath := path.Join("testdata", "recipe", "recipe.json") + parameters := []string{ + testutil.GetBicepRecipeRegistry(), + testutil.GetBicepRecipeVersion(), + fmt.Sprintf("name=%s", name), + fmt.Sprintf("namespace=%s", namespace), + } + + providerConfig, err := sdkclients.NewDefaultProviderConfig(name).String() + require.NoError(t, err) + + parametersMap := createParametersMap(parameters) + + template, err := os.ReadFile(templateFilePath) + require.NoError(t, err) + + // Create the namespace, if it already exists we can ignore the error. + _, err = opts.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, metav1.CreateOptions{}) + require.NoError(t, controller_runtime.IgnoreAlreadyExists(err)) + + deploymentTemplate := makeDeploymentTemplate(types.NamespacedName{Name: name, Namespace: namespace}, string(template), providerConfig, parametersMap) + + t.Run("Create DeploymentTemplate", func(t *testing.T) { + t.Log("Creating DeploymentTemplate") + err = opts.Client.Create(ctx, deploymentTemplate) + require.NoError(t, err) + }) + + t.Run("Check DeploymentTemplate status", func(t *testing.T) { + ctx, cancel := testcontext.NewWithCancel(t) + defer cancel() + + // Get resource version + err = opts.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, deploymentTemplate) + require.NoError(t, err) + + t.Log("Waiting for DeploymentTemplate ready") + deploymentTemplate, err := waitForDeploymentTemplateReady(t, ctx, types.NamespacedName{Name: name, Namespace: namespace}, opts.Client, deploymentTemplate.ResourceVersion) + require.NoError(t, err) + + scope, err := reconciler.ParseDeploymentScopeFromProviderConfig(deploymentTemplate.Spec.ProviderConfig) + require.NoError(t, err) + + expectedResources := [][]string{ + {"Applications.Core/environments", fmt.Sprintf("%s-env", name)}, + {"Applications.Core/applications", fmt.Sprintf("%s-app", name)}, + {"Applications.Datastores/redisCaches", fmt.Sprintf("%s-recipe", name)}, + } + + assertExpectedResourcesExist(t, ctx, scope, expectedResources, opts.Connection) + }) + + t.Run("Delete DeploymentTemplate", func(t *testing.T) { + t.Log("Deleting DeploymentTemplate") + err = opts.Client.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + require.Eventually(t, func() bool { + err = opts.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, deploymentTemplate) + return apierrors.IsNotFound(err) + }, time.Minute*3, time.Second*5, "waiting for deploymentTemplate to be deleted") + }) +} + +// makeDeploymentTemplate returns a DeploymentTemplate object with the given name, template, providerConfig, and parameters. +func makeDeploymentTemplate(name types.NamespacedName, template, providerConfig string, parameters map[string]string) *radappiov1alpha3.DeploymentTemplate { + deploymentTemplate := &radappiov1alpha3.DeploymentTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: name.Name, + Namespace: name.Namespace, + }, + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: template, + Parameters: parameters, + ProviderConfig: providerConfig, + }, + } + + return deploymentTemplate +} + +// waitForDeploymentTemplateReady watches the creation of the DeploymentTemplate object +// and waits for it to be in the "Ready" state. +func waitForDeploymentTemplateReady(t *testing.T, ctx context.Context, name types.NamespacedName, client controller_runtime.WithWatch, initialVersion string) (*radappiov1alpha3.DeploymentTemplate, error) { + // Based on https://gist.github.com/PrasadG193/52faed6499d2ec739f9630b9d044ffdc + lister := &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + listOptions := &controller_runtime.ListOptions{Raw: &options, Namespace: name.Namespace, FieldSelector: fields.ParseSelectorOrDie("metadata.name=" + name.Name)} + deploymentTemplates := &radappiov1alpha3.DeploymentTemplateList{} + err := client.List(ctx, deploymentTemplates, listOptions) + if err != nil { + return nil, err + } + + return deploymentTemplates, nil + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + listOptions := &controller_runtime.ListOptions{Raw: &options, Namespace: name.Namespace, FieldSelector: fields.ParseSelectorOrDie("metadata.name=" + name.Name)} + deploymentTemplates := &radappiov1alpha3.DeploymentTemplateList{} + return client.Watch(ctx, deploymentTemplates, listOptions) + }, + } + watcher, err := watchtools.NewRetryWatcher(initialVersion, lister) + require.NoError(t, err) + defer watcher.Stop() + + for { + event := <-watcher.ResultChan() + r, ok := event.Object.(*radappiov1alpha3.DeploymentTemplate) + if !ok { + // Not a deploymentTemplate, likely an event. + t.Logf("Received event: %+v", event) + continue + } + + t.Logf("Received deploymentTemplate. Status: %+v", r.Status) + if r.Status.Phrase == radappiov1alpha3.DeploymentTemplatePhraseReady { + return r, nil + } + } +} + +// createParametersMap creates a map of parameters from a list of parameters +// in the form of key=value. +func createParametersMap(parameters []string) map[string]string { + parametersMap := make(map[string]string) + for _, param := range parameters { + kv := strings.Split(param, "=") + key := kv[0] + value := kv[1] + parametersMap[key] = value + } + + return parametersMap +} + +// assertExpectedResourcesExist asserts that the expected resources exist +// in Radius for the given scope. +func assertExpectedResourcesExist(t *testing.T, ctx context.Context, scope string, expectedResources [][]string, connection sdk.Connection) { + for _, resource := range expectedResources { + resourceType := resource[0] + resourceName := resource[1] + + client, err := generated.NewGenericResourcesClient(scope, resourceType, &aztoken.AnonymousCredential{}, sdk.NewClientOptions(connection)) + require.NoError(t, err) + + _, err = client.Get(ctx, resourceName, nil) + require.NoError(t, err) + } +} diff --git a/test/functional-portable/kubernetes/noncloud/testdata/env/env.bicep b/test/functional-portable/kubernetes/noncloud/testdata/env/env.bicep new file mode 100644 index 0000000000..2044caa2bc --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/testdata/env/env.bicep @@ -0,0 +1,15 @@ +extension radius + +param name string +param namespace string + +resource env 'Applications.Core/environments@2023-10-01-preview' = { + name: '${name}-env' + properties: { + compute: { + kind: 'kubernetes' + resourceId: 'self' + namespace: namespace + } + } +} diff --git a/test/functional-portable/kubernetes/noncloud/testdata/env/env.json b/test/functional-portable/kubernetes/noncloud/testdata/env/env.json new file mode 100644 index 0000000000..201939108b --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/testdata/env/env.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.32.4.45862", + "templateHash": "17296380169561690776" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "env": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "[format('{0}-env', parameters('name'))]", + "properties": { + "compute": { + "kind": "kubernetes", + "resourceId": "self", + "namespace": "[parameters('namespace')]" + } + } + } + } + } +} diff --git a/test/functional-portable/kubernetes/noncloud/testdata/module/module-dependency.bicep b/test/functional-portable/kubernetes/noncloud/testdata/module/module-dependency.bicep new file mode 100644 index 0000000000..0068ac1c3a --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/testdata/module/module-dependency.bicep @@ -0,0 +1,20 @@ +extension radius + +param name string +param envId string +param namespace string + +resource app 'Applications.Core/applications@2023-10-01-preview' = { + name: '${name}-app' + properties: { + environment: envId + extensions: [ + { + kind: 'kubernetesNamespace' + namespace: namespace + } + ] + } +} + +output appId string = app.id diff --git a/test/functional-portable/kubernetes/noncloud/testdata/module/module.bicep b/test/functional-portable/kubernetes/noncloud/testdata/module/module.bicep new file mode 100644 index 0000000000..5b53d5f980 --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/testdata/module/module.bicep @@ -0,0 +1,24 @@ +extension radius + +param name string +param namespace string + +resource env 'Applications.Core/environments@2023-10-01-preview' = { + name: '${name}-env' + properties: { + compute: { + kind: 'kubernetes' + resourceId: 'self' + namespace: '${name}-env' + } + } +} + +module module 'module-dependency.bicep' = { + name: 'module' + params: { + name: name + envId: env.id + namespace: namespace + } +} diff --git a/test/functional-portable/kubernetes/noncloud/testdata/module/module.json b/test/functional-portable/kubernetes/noncloud/testdata/module/module.json new file mode 100644 index 0000000000..2202ad01dc --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/testdata/module/module.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.32.4.45862", + "templateHash": "10911240203111091281" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "env": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "[format('{0}-env', parameters('name'))]", + "properties": { + "compute": { + "kind": "kubernetes", + "resourceId": "self", + "namespace": "[format('{0}-env', parameters('name'))]" + } + } + } + }, + "module": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "module", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[parameters('name')]" + }, + "envId": { + "value": "[reference('env').id]" + }, + "namespace": { + "value": "[parameters('namespace')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.32.4.45862", + "templateHash": "5602568770499112182" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "envId": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "app": { + "import": "Radius", + "type": "Applications.Core/applications@2023-10-01-preview", + "properties": { + "name": "[format('{0}-app', parameters('name'))]", + "properties": { + "environment": "[parameters('envId')]", + "extensions": [ + { + "kind": "kubernetesNamespace", + "namespace": "[parameters('namespace')]" + } + ] + } + } + } + }, + "outputs": { + "appId": { + "type": "string", + "value": "[reference('app').id]" + } + } + } + }, + "dependsOn": ["env"] + } + } +} diff --git a/test/functional-portable/kubernetes/noncloud/testdata/recipe/recipe.bicep b/test/functional-portable/kubernetes/noncloud/testdata/recipe/recipe.bicep new file mode 100644 index 0000000000..4c60fb67db --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/testdata/recipe/recipe.bicep @@ -0,0 +1,46 @@ +extension radius + +param name string +param namespace string +param registry string +param version string + +resource env 'Applications.Core/environments@2023-10-01-preview' = { + name: '${name}-env' + properties: { + compute: { + kind: 'kubernetes' + resourceId: 'self' + namespace: '${name}-env' + } + recipes: { + 'Applications.Datastores/redisCaches': { + default: { + templateKind: 'bicep' + templatePath: '${registry}/test/testrecipes/test-bicep-recipes/redis-recipe-value-backed:${version}' + } + } + } + } +} + +resource app 'Applications.Core/applications@2023-10-01-preview' = { + name: '${name}-app' + properties: { + environment: env.id + extensions: [ + { + kind: 'kubernetesNamespace' + namespace: namespace + } + ] + } +} + +resource recipe 'Applications.Datastores/redisCaches@2023-10-01-preview' = { + name: '${name}-recipe' + properties: { + application: app.id + environment: env.id + } +} diff --git a/test/functional-portable/kubernetes/noncloud/testdata/recipe/recipe.json b/test/functional-portable/kubernetes/noncloud/testdata/recipe/recipe.json new file mode 100644 index 0000000000..f55381232e --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/testdata/recipe/recipe.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.1-experimental", + "contentVersion": "1.0.0.0", + "metadata": { + "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.", + "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"], + "_generator": { + "name": "bicep", + "version": "0.32.4.45862", + "templateHash": "11540297415417574795" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "registry": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "imports": { + "Radius": { + "provider": "Radius", + "version": "latest" + } + }, + "resources": { + "env": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "[format('{0}-env', parameters('name'))]", + "properties": { + "compute": { + "kind": "kubernetes", + "resourceId": "self", + "namespace": "[format('{0}-env', parameters('name'))]" + }, + "recipes": { + "Applications.Datastores/redisCaches": { + "default": { + "templateKind": "bicep", + "templatePath": "[format('{0}/test/testrecipes/test-bicep-recipes/redis-recipe-value-backed:{1}', parameters('registry'), parameters('version'))]" + } + } + } + } + } + }, + "app": { + "import": "Radius", + "type": "Applications.Core/applications@2023-10-01-preview", + "properties": { + "name": "[format('{0}-app', parameters('name'))]", + "properties": { + "environment": "[reference('env').id]", + "extensions": [ + { + "kind": "kubernetesNamespace", + "namespace": "[parameters('namespace')]" + } + ] + } + }, + "dependsOn": ["env"] + }, + "recipe": { + "import": "Radius", + "type": "Applications.Datastores/redisCaches@2023-10-01-preview", + "properties": { + "name": "[format('{0}-recipe', parameters('name'))]", + "properties": { + "application": "[reference('app').id]", + "environment": "[reference('env').id]" + } + }, + "dependsOn": ["app", "env"] + } + } +}