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..3049d6a67b --- /dev/null +++ b/deploy/Chart/crds/radius/radapp.io_deploymentresources.yaml @@ -0,0 +1,93 @@ +--- +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: + - 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 DeploymentResource + properties: + id: + description: Id is the resource Id. + type: string + providerConfig: + description: ProviderConfig specifies the scope for resources + type: string + type: object + status: + description: DeploymentResourceStatus defines the observed state of DeploymentResource + properties: + id: + description: Id is the resource Id. + type: string + message: + description: Message is a human-readable description of the status + of the Deployment 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 + providerConfig: + description: ProviderConfig specifies the scope for resources + 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..3df79ca1d4 --- /dev/null +++ b/deploy/Chart/crds/radius/radapp.io_deploymenttemplates.yaml @@ -0,0 +1,105 @@ +--- +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: + - 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 DeploymentTemplate + properties: + parameters: + additionalProperties: + type: string + description: Parameters is the ARM JSON parameters for the template. + type: object + providerConfig: + description: ProviderConfig specifies the scope 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 DeploymentTemplate + properties: + message: + description: Message is a human-readable description of the status + of the Deployment Template. + type: string + 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 + resource: + description: Resource is the resource id of the deployment. + type: string + statusHash: + description: StatusHash is a hash of the DeploymentTemplate's status. + 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..282f78fcf1 --- /dev/null +++ b/pkg/cli/cmd/bicep/generatekubernetesmanifest/generatekubernetesmanifest.go @@ -0,0 +1,285 @@ +/* +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" + "fmt" + "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.") + _ = cmd.MarkFlagFilename("destination-file", ".yaml") + + 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 deploy` 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" { + return clierrors.Message("Destination file must have a .yaml 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 + } + + // Print the path to the file + 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 := r.generateProviderConfig() + + marshalledProviderConfig, err := json.MarshalIndent(providerConfig, "", " ") + 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": string(marshalledProviderConfig), + }, + } + + 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) + + // Set the indentation to 2 spaces + encoder.SetIndent(2) + + err := encoder.Encode(deploymentTemplate) + if err != nil { + return err + } + + return r.FileSystem.WriteFile(r.DestinationFile, buf.Bytes(), 0644) +} + +// generateProviderConfig generates a ProviderConfig object based on the given scopes. +func (r *Runner) generateProviderConfig() (providerConfig sdkclients.ProviderConfig) { + providerConfig = sdkclients.ProviderConfig{} + if r.AWSScope != "" { + providerConfig.AWS = &sdkclients.AWS{ + Type: "aws", + Value: sdkclients.Value{ + Scope: r.AWSScope, + }, + } + } + if r.AzureScope != "" { + providerConfig.Az = &sdkclients.Az{ + Type: "azure", + Value: sdkclients.Value{ + Scope: r.AzureScope, + }, + } + } + if r.Group != "" { + providerConfig.Radius = &sdkclients.Radius{ + Type: "radius", + Value: sdkclients.Value{ + Scope: constructRadiusDeploymentScope(r.Group), + }, + } + providerConfig.Deployments = &sdkclients.Deployments{ + Type: "Microsoft.Resources", + Value: sdkclients.Value{ + Scope: constructRadiusDeploymentScope(r.Group), + }, + } + } + + return providerConfig +} + +func constructRadiusDeploymentScope(group string) string { + return fmt.Sprintf("/planes/radius/local/resourceGroups/%s", group) +} 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..5d8e8d14af --- /dev/null +++ b/pkg/cli/cmd/bicep/generatekubernetesmanifest/generatekubernetesmanifest_test.go @@ -0,0 +1,197 @@ +/* +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 - 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..d093174678 --- /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/deploy/deploy_test.go b/pkg/cli/cmd/deploy/deploy_test.go index 30635aa866..954b5e8a4d 100644 --- a/pkg/cli/cmd/deploy/deploy_test.go +++ b/pkg/cli/cmd/deploy/deploy_test.go @@ -527,7 +527,6 @@ func Test_Run(t *testing.T) { }) t.Run("Deployment with missing parameters", func(t *testing.T) { - //t.Skip() ctrl := gomock.NewController(t) defer ctrl.Finish() 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/filesystem/filesystem.go b/pkg/cli/filesystem/filesystem.go new file mode 100644 index 0000000000..aedb40b1e2 --- /dev/null +++ b/pkg/cli/filesystem/filesystem.go @@ -0,0 +1,15 @@ +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..9bf9e329bc --- /dev/null +++ b/pkg/cli/filesystem/memmapfs.go @@ -0,0 +1,52 @@ +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..c48539208b --- /dev/null +++ b/pkg/cli/filesystem/memmapfs_test.go @@ -0,0 +1,83 @@ +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..3647e17844 --- /dev/null +++ b/pkg/cli/filesystem/osfs.go @@ -0,0 +1,41 @@ +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..077d1fa667 --- /dev/null +++ b/pkg/controller/api/radapp.io/v1alpha3/deploymentresource_types.go @@ -0,0 +1,95 @@ +/* +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 DeploymentResource +type DeploymentResourceSpec struct { + // Id is the resource Id. + Id string `json:"id,omitempty"` + + // ProviderConfig specifies the scope for resources + ProviderConfig string `json:"providerConfig,omitempty"` +} + +// DeploymentResourceStatus defines the observed state of DeploymentResource +type DeploymentResourceStatus struct { + // Id is the resource Id. + Id string `json:"id,omitempty"` + + // ProviderConfig specifies the scope for resources + ProviderConfig string `json:"providerConfig,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"` + + // Message is a human-readable description of the status of the Deployment Resource. + Message string `json:"message,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..8472510aa0 --- /dev/null +++ b/pkg/controller/api/radapp.io/v1alpha3/deploymenttemplate_types.go @@ -0,0 +1,104 @@ +/* +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 DeploymentTemplate +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 scope for resources + ProviderConfig string `json:"providerConfig,omitempty"` +} + +// DeploymentTemplateStatus defines the observed state of DeploymentTemplate +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 status. + StatusHash string `json:"statusHash,omitempty"` + + // Resource is the resource id of the deployment. + Resource string `json:"resource,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"` + + // Message is a human-readable description of the status of the Deployment Template. + Message string `json:"message,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..7d3db59458 100644 --- a/pkg/controller/reconciler/deployment_reconciler.go +++ b/pkg/controller/reconciler/deployment_reconciler.go @@ -577,6 +577,7 @@ func (r *DeploymentReconciler) updateDeployment(ctx context.Context, deployment // Add the hash of the secret data to the Pod definition. This will force a rollout when the secrets // change. + // TODOWILLSMITH: here hash := kubernetes.HashSecretData(secret.Data) if deployment.Spec.Template.ObjectMeta.Annotations == nil { deployment.Spec.Template.ObjectMeta.Annotations = map[string]string{} 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..6965d7446f --- /dev/null +++ b/pkg/controller/reconciler/deploymentresource_reconciler.go @@ -0,0 +1,397 @@ +/* +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" + "encoding/json" + "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" + "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/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 + + // 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.", "resourceId", deploymentResource.Spec.Id) + + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseReady + deploymentResource.Status.ProviderConfig = deploymentResource.Spec.ProviderConfig + 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 { + providerConfig := sdkclients.ProviderConfig{} + err := json.Unmarshal([]byte(deploymentResource.Spec.ProviderConfig), &providerConfig) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to unmarshal providerConfig: %w", err) + } + + poller, err := r.Radius.Resources(providerConfig.Deployments.Value.Scope, deploymentResourceType).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.", "resourceId", deploymentResource.Spec.Id) + + // 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 + + // 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 + } + + poller, 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 poller != nil { + // We've successfully started an operation. Update the status and requeue. + token, err := poller.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} + deploymentResource.Status.Phrase = radappiov1alpha3.DeploymentResourcePhraseDeleting + err = r.Client.Status().Update(ctx, deploymentResource) + 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 + // 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 + } + } + + logger.Info("Finalizer was not removed, requeueing.") + + err = r.Client.Status().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. + + return ctrl.Result{Requeue: true, RequeueAfter: r.requeueDelay()}, nil +} + +func (r *DeploymentResourceReconciler) startDeleteOperation(ctx context.Context, deploymentResource *radappiov1alpha3.DeploymentResource) (Poller[generated.GenericResourcesClientDeleteResponse], error) { + logger := ucplog.FromContextOrDiscard(ctx) + + resourceId := deploymentResource.Spec.Id + + logger.Info("Starting DELETE operation.") + poller, err := deleteResource(ctx, r.Radius, resourceId) + 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 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 strings.EqualFold(deploymentResourceID.Type(), "Applications.Core/applications") { + return "", nil + } + + if strings.EqualFold(deploymentResourceID.Type(), "Applications.Core/environments") { + return "", nil + } + + resourceCount := 0 + dependentResource := "" + for _, dr := range deploymentResourceList { + // shouldn't need this... + // 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 +} diff --git a/pkg/controller/reconciler/deploymentresource_reconciler_test.go b/pkg/controller/reconciler/deploymentresource_reconciler_test.go new file mode 100644 index 0000000000..1327bd5605 --- /dev/null +++ b/pkg/controller/reconciler/deploymentresource_reconciler_test.go @@ -0,0 +1,182 @@ +/* +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" + "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" + "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, client.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) + + radius := NewMockRadiusClient() + err = (&DeploymentResourceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventRecorder: mgr.GetEventRecorderFor("deploymentresource-controller"), + Radius: radius, + DelayInterval: DeploymentResourceTestControllerDelayInterval, + }).SetupWithManager(mgr) + require.NoError(t, err) + + go func() { + err := mgr.Start(ctx) + require.NoError(t, err) + }() + + return radius, mgr.GetClient() +} + +func Test_DeploymentResourceReconciler_Basic(t *testing.T) { + ctx := testcontext.New(t) + _, client := SetupDeploymentResourceTest(t) + + name := types.NamespacedName{Namespace: TestDeploymentResourceNamespace, Name: TestDeploymentResourceName} + err := client.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: name.Namespace}}) + require.NoError(t, err) + + deployment := makeDeploymentResource(name, TestDeploymentResourceID) + err = client.Create(ctx, deployment) + require.NoError(t, err) + + // Deployment will update after operation completes + status := waitForDeploymentResourceStateReady(t, client, name) + require.Equal(t, TestDeploymentResourceID, status.Id) + + err = client.Delete(ctx, deployment) + require.NoError(t, err) + + // Now deleting of the DeploymentResource object can complete. + waitForDeploymentResourceDeleted(t, client, name) +} + +func waitForDeploymentResourceStateReady(t *testing.T, client client.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 client.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 client.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..ed32ff86ca --- /dev/null +++ b/pkg/controller/reconciler/deploymenttemplate_reconciler.go @@ -0,0 +1,566 @@ +/* +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" + "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_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" +) + +const ( + deploymentResourceType = "Microsoft.Resources/deployments" +) + +// DeploymentTemplateReconciler reconciles a DeploymentTemplate object. +type DeploymentTemplateReconciler 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 + + // 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` and `status.message` as `Failed` with the reason for the failure and continue processing. + // 2. If the `DeploymentTemplate` is being deleted, then process deletion: + // 1. Remove the `radapp.io/deployment-template-finalizer` finalizer from the `DeploymentTemplate`. + // 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. + // 4. 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. 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. + // 3. Set the `status.phrase` for the `DeploymentTemplate` to `Updating` and the `status.operation` to the operation returned by the Radius API. + // 4. 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() { + // 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 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 { + scope, err := ParseDeploymentScopeFromProviderConfig(deploymentTemplate.Spec.ProviderConfig) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to parse deployment scope: %w", err) + } + + poller, err := r.Radius.Resources(scope, deploymentResourceType).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 + deploymentTemplate.Status.Message = err.Error() + 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["outputResources"] != nil { + outputResourceList := resp.Properties["outputResources"].([]any) + for _, resource := range outputResourceList { + outputResource := resource.(map[string]any) + outputResources = append(outputResources, outputResource["id"].(string)) + } + + // 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 + + 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, + ProviderConfig: deploymentTemplate.Spec.ProviderConfig, + }, + } + + 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 + } + } + } + } + + providerConfig := sdkclients.ProviderConfig{} + err = json.Unmarshal([]byte(deploymentTemplate.Spec.ProviderConfig), &providerConfig) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to unmarshal providerConfig: %w", 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. + // + // NOTE: we don't need to save the status here, because we're going to continue reconciling. + deploymentTemplate.Status.Operation = nil + deploymentTemplate.Status.OutputResources = outputResources + deploymentTemplate.Status.StatusHash = hash + deploymentTemplate.Status.Resource = providerConfig.Deployments.Value.Scope + "/providers/" + deploymentResourceType + "/" + deploymentTemplate.Name + + 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 + deploymentTemplate.Status.Message = errorMessage.Error() + 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 + deploymentTemplate.Status.Message = err.Error() + 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.", "resourceId", deploymentTemplate.Status.Resource) + + 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.", "resourceId", deploymentTemplate.Status.Resource) + + // 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 + } + } + + 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("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) (Poller[generated.GenericResourcesClientCreateOrUpdateResponse], 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) + } + + logger.Info("Starting PUT operation.") + properties := map[string]any{ + "mode": "Incremental", + "providerConfig": providerConfig, + "template": template, + "parameters": specParameters, + } + + resourceID := providerConfig.Deployments.Value.Scope + "/providers/" + deploymentResourceType + "/" + deploymentTemplate.Name + poller, err := createOrUpdateResource(ctx, r.Radius, resourceID, properties) + if err != nil { + return nil, err + } else if poller != nil { + return poller, nil + } + + // Update was synchronous + deploymentTemplate.Status.Resource = resourceID + err = r.Client.Status().Update(ctx, deploymentTemplate) + if err != nil { + return nil, err + } + + 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). +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..c7fcb2ca67 --- /dev/null +++ b/pkg/controller/reconciler/deploymenttemplate_reconciler_test.go @@ -0,0 +1,538 @@ +/* +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/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/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" + "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, client.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) + + radius := NewMockRadiusClient() + + // Set up DeploymentTemplateReconciler. + err = (&DeploymentTemplateReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventRecorder: mgr.GetEventRecorderFor("deploymenttemplate-controller"), + Radius: radius, + 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: radius, + DelayInterval: DeploymentResourceTestControllerDelayInterval, + }).SetupWithManager(mgr) + require.NoError(t, err) + + go func() { + err := mgr.Start(ctx) + require.NoError(t, err) + }() + + return radius, 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_DeploymentTemplateReconciler_Basic(t *testing.T) { + ctx := testcontext.New(t) + radius, client := SetupDeploymentTemplateTest(t) + + name := types.NamespacedName{Namespace: "deploymenttemplate-basic", Name: "test-deploymenttemplate-basic"} + err := client.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: name.Namespace}}) + require.NoError(t, err) + + scope := "/planes/radius/local/resourcegroups/deploymenttemplate-basic" + providerConfig, err := generateProviderConfig(scope, "", "") + require.NoError(t, err) + + deploymentTemplate := makeDeploymentTemplate(name, "{}", providerConfig, map[string]string{}) + err = client.Create(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to enter the updating state. + status := waitForDeploymentTemplateStateUpdating(t, client, name, nil) + + radius.CompleteOperation(status.Operation.ResumeToken, nil) + + // DeploymentTemplate should be ready after the operation completes. + status = waitForDeploymentTemplateStateReady(t, client, name) + require.Equal(t, "/planes/radius/local/resourcegroups/deploymenttemplate-basic/providers/Microsoft.Resources/deployments/test-deploymenttemplate-basic", status.Resource) + + // Verify that the Radius deployment contains the expected properties. + expectedProperties := map[string]any{ + "mode": "Incremental", + "template": map[string]any{}, + "parameters": map[string]map[string]string{}, + "providerConfig": sdkclients.ProviderConfig{ + Radius: &sdkclients.Radius{ + Type: "Radius", + Value: sdkclients.Value{ + Scope: "/planes/radius/local/resourcegroups/deploymenttemplate-basic", + }, + }, + Deployments: &sdkclients.Deployments{ + Type: "Microsoft.Resources", + Value: sdkclients.Value{ + Scope: "/planes/radius/local/resourcegroups/deploymenttemplate-basic", + }, + }, + }, + } + resource, err := radius.Resources("/planes/radius/local/resourcegroups/deploymenttemplate-basic", "Microsoft.Resources/deployments").Get(ctx, name.Name) + require.NoError(t, err) + require.Equal(t, expectedProperties, resource.Properties) + + // Verify that the DeploymentTemplate contains the expected properties. + expectedDeploymentTemplateSpec := &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: "{}", + Parameters: map[string]string{}, + ProviderConfig: providerConfig, + }, + } + + expectedStatusHash, err := computeHash(expectedDeploymentTemplateSpec) + require.NoError(t, err) + + require.Equal(t, expectedStatusHash, status.StatusHash) + + // Delete the DeploymentTemplate + err = client.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to be deleted. + waitForDeploymentTemplateStateDeleted(t, client, name) +} + +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) + radius, client := SetupDeploymentTemplateTest(t) + + name := types.NamespacedName{Namespace: "deploymenttemplate-failurerecovery", Name: "test-deploymenttemplate-failurerecovery"} + err := client.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: name.Namespace}}) + require.NoError(t, err) + + scope := "/planes/radius/local/resourcegroups/deploymenttemplate-failurerecovery" + providerConfig, err := generateProviderConfig(scope, "", "") + require.NoError(t, err) + + deploymentTemplate := makeDeploymentTemplate(name, "{}", providerConfig, map[string]string{}) + err = client.Create(ctx, deploymentTemplate) + require.NoError(t, err) + + // Wait for the DeploymentTemplate to enter the updating state. + status := waitForDeploymentTemplateStateUpdating(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("failure") + + resource, ok := radius.resources[state.resourceID] + require.True(t, ok, "failed to find resource") + + resource.Properties["provisioningState"] = "Failed" + state.value = generated.GenericResourcesClientCreateOrUpdateResponse{GenericResource: resource} + }) + + // DeploymentTemplate should (eventually) start a new provisioning operation + status = waitForDeploymentTemplateStateUpdating(t, client, name, operation) + + // Complete the operation, successfully this time. + radius.CompleteOperation(status.Operation.ResumeToken, nil) + _ = waitForDeploymentTemplateStateReady(t, client, name) + + err = client.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + waitForDeploymentTemplateStateDeleted(t, client, name) +} + +func Test_DeploymentTemplateReconciler_WithResources(t *testing.T) { + ctx := testcontext.New(t) + radius, client := SetupDeploymentTemplateTest(t) + + name := types.NamespacedName{Namespace: "deploymenttemplate-withresources", Name: "test-deploymenttemplate-withresources"} + err := client.Create(ctx, &corev1.Namespace{ObjectMeta: ctrl.ObjectMeta{Name: name.Namespace}}) + require.NoError(t, err) + + fileContent, err := os.ReadFile(path.Join("testdata", "deploymenttemplate-withresources.json")) + 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) + + scope := "/planes/radius/local/resourcegroups/deploymenttemplate-withresources" + providerConfig, err := generateProviderConfig(scope, "", "") + require.NoError(t, err) + + deploymentTemplate := makeDeploymentTemplate(name, string(template), providerConfig, map[string]string{}) + err = client.Create(ctx, deploymentTemplate) + require.NoError(t, err) + + status := waitForDeploymentTemplateStateUpdating(t, client, name, nil) + + radius.CompleteOperation(status.Operation.ResumeToken, func(state *operationState) { + resource, ok := radius.resources[state.resourceID] + require.True(t, ok, "failed to find resource") + + resource.Properties["outputResources"] = []any{ + map[string]any{"id": "/planes/radius/local/resourcegroups/deploymenttemplate-withresources/providers/Applications.Core/environments/env"}, + } + state.value = generated.GenericResourcesClientCreateOrUpdateResponse{GenericResource: resource} + }) + + // DeploymentTemplate should be ready after the operation completes. + status = waitForDeploymentTemplateStateReady(t, client, name) + require.Equal(t, "/planes/radius/local/resourcegroups/deploymenttemplate-withresources/providers/Microsoft.Resources/deployments/test-deploymenttemplate-withresources", status.Resource) + + // DeploymentTemplate will be waiting for environment to be created. + createEnvironment(radius, "deploymenttemplate-withresources", "env") + + dependencyName := types.NamespacedName{Namespace: name.Namespace, Name: "env"} + dependencyStatus := waitForDeploymentResourceStateReady(t, client, dependencyName) + require.Equal(t, "/planes/radius/local/resourcegroups/deploymenttemplate-withresources/providers/Applications.Core/environments/env", dependencyStatus.Id) + + // Verify that the Radius deployment contains the expected properties. + resource, err := radius.Resources(scope, "Microsoft.Resources/deployments").Get(ctx, name.Name) + require.NoError(t, err) + expectedProperties := map[string]any{ + "mode": "Incremental", + "template": templateMap, + "parameters": map[string]map[string]string{}, + "providerConfig": sdkclients.ProviderConfig{ + Radius: &sdkclients.Radius{ + Type: "Radius", + Value: sdkclients.Value{ + Scope: "/planes/radius/local/resourcegroups/deploymenttemplate-withresources", + }, + }, + Deployments: &sdkclients.Deployments{ + Type: "Microsoft.Resources", + Value: sdkclients.Value{ + Scope: "/planes/radius/local/resourcegroups/deploymenttemplate-withresources", + }, + }, + }, + "outputResources": []any{ + map[string]any{"id": "/planes/radius/local/resourcegroups/deploymenttemplate-withresources/providers/Applications.Core/environments/env"}, + }, + } + require.Equal(t, expectedProperties, resource.Properties) + + // Verify that the DeploymentTemplate contains the expected properties. + expectedDeploymentTemplateSpec := &radappiov1alpha3.DeploymentTemplate{ + Spec: radappiov1alpha3.DeploymentTemplateSpec{ + Template: string(template), + Parameters: map[string]string{}, + ProviderConfig: providerConfig, + }, + } + + expectedStatusHash, err := computeHash(expectedDeploymentTemplateSpec) + require.NoError(t, err) + + require.Equal(t, expectedStatusHash, status.StatusHash) + + err = client.Delete(ctx, deploymentTemplate) + require.NoError(t, err) + + waitForDeploymentTemplateStateDeleting(t, client, name) + + dependencyStatus = waitForDeploymentResourceStateDeleting(t, client, dependencyName, nil) + + // Delete the environment. + deleteEnvironment(radius, "deploymenttemplate-withresources", "env") + + // Complete the delete operation on the DeploymentResource. + radius.CompleteOperation(dependencyStatus.Operation.ResumeToken, nil) + + waitForDeploymentResourceDeleted(t, client, dependencyName) + waitForDeploymentTemplateStateDeleted(t, client, name) +} + +func waitForDeploymentTemplateStateUpdating(t *testing.T, client client.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 client.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 client.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 client.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") +} diff --git a/pkg/controller/reconciler/mock_client_test.go b/pkg/controller/reconciler/mock_client_test.go index 08bd96c2e8..d5eb8b5008 100644 --- a/pkg/controller/reconciler/mock_client_test.go +++ b/pkg/controller/reconciler/mock_client_test.go @@ -75,6 +75,13 @@ func (rc *mockRadiusClient) Update(exec func()) { exec() } +func (rc *mockRadiusClient) Delete(exec func()) { + rc.lock.Lock() + defer rc.lock.Unlock() + + exec() +} + func (rc *mockRadiusClient) Applications(scope string) ApplicationClient { return &mockApplicationClient{mock: rc, scope: scope} } diff --git a/pkg/controller/reconciler/recipe_reconciler_test.go b/pkg/controller/reconciler/recipe_reconciler_test.go index d403829a45..d8a3dadff4 100644 --- a/pkg/controller/reconciler/recipe_reconciler_test.go +++ b/pkg/controller/reconciler/recipe_reconciler_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/radius-project/radius/pkg/cli/clients_new/generated" + "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 +50,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 +91,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 +133,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 +147,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,7 +210,7 @@ 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) @@ -275,7 +276,7 @@ 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) 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..91e50f3697 100644 --- a/pkg/controller/reconciler/shared_test.go +++ b/pkg/controller/reconciler/shared_test.go @@ -17,6 +17,7 @@ limitations under the License. package reconciler import ( + "encoding/json" "fmt" "testing" "time" @@ -24,6 +25,7 @@ import ( v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3" "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" "github.com/radius-project/radius/test/testcontext" "github.com/stretchr/testify/assert" @@ -43,8 +45,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), @@ -54,6 +56,13 @@ func createEnvironment(radius *mockRadiusClient, name string) { }) } +func deleteEnvironment(radius *mockRadiusClient, resourceGroup, name string) { + id := fmt.Sprintf("/planes/radius/local/resourceGroups/%s/providers/Applications.Core/environments/%s", resourceGroup, name) + radius.Delete(func() { + delete(radius.environments, id) + }) +} + func makeRecipe(name types.NamespacedName, resourceType string) *radappiov1alpha3.Recipe { return &radappiov1alpha3.Recipe{ ObjectMeta: ctrl.ObjectMeta{ @@ -188,6 +197,69 @@ 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, + }, + } +} + +func generateProviderConfig(radiusScope, azureScope, awsScope string) (string, error) { + if radiusScope == "" { + return "", fmt.Errorf("radiusScope is required") + } + + providerConfig := sdkclients.ProviderConfig{} + if awsScope != "" { + providerConfig.AWS = &sdkclients.AWS{ + Type: "aws", + Value: sdkclients.Value{ + Scope: awsScope, + }, + } + } + if azureScope != "" { + providerConfig.Az = &sdkclients.Az{ + Type: "azure", + Value: sdkclients.Value{ + Scope: azureScope, + }, + } + } + + providerConfig.Radius = &sdkclients.Radius{ + Type: "Radius", + Value: sdkclients.Value{ + Scope: radiusScope, + }, + } + providerConfig.Deployments = &sdkclients.Deployments{ + Type: "Microsoft.Resources", + Value: sdkclients.Value{ + Scope: radiusScope, + }, + } + + b, err := json.Marshal(providerConfig) + + return string(b), err } diff --git a/pkg/controller/reconciler/testdata/deploymenttemplate-withresources.json b/pkg/controller/reconciler/testdata/deploymenttemplate-withresources.json new file mode 100644 index 0000000000..222ede5618 --- /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": { + "env": { + "import": "Radius", + "type": "Applications.Core/environments@2023-10-01-preview", + "properties": { + "name": "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..91c027d1e1 100644 --- a/pkg/controller/reconciler/util.go +++ b/pkg/controller/reconciler/util.go @@ -280,3 +280,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..bd9a381f8f 100644 --- a/pkg/controller/service.go +++ b/pkg/controller/service.go @@ -107,6 +107,24 @@ func (s *Service) Run(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to setup %s controller: %w", "Deployment", err) } + err = (&reconciler.DeploymentTemplateReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + EventRecorder: mgr.GetEventRecorderFor("deploymenttemplate-controller"), + Radius: reconciler.NewClient(s.Options.UCPConnection), + }).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.NewClient(s.Options.UCPConnection), + }).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.") diff --git a/pkg/sdk/clients/resourcedeploymentsclient.go b/pkg/sdk/clients/resourcedeploymentsclient.go index 11a3a2b72a..1cc67d363d 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"` 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..3ffa49e1df --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/deploymenttemplate_test.go @@ -0,0 +1,358 @@ +/* +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" + "encoding/json" + "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_Basic(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 := generateDefaultProviderConfig() + 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 := generateDefaultProviderConfig() + 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 := generateDefaultProviderConfig() + 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") + }) +} + +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 +} + +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 + } + } +} + +func generateDefaultProviderConfig() (string, error) { + providerConfig := sdkclients.ProviderConfig{} + + providerConfig.Radius = &sdkclients.Radius{ + Type: "radius", + Value: sdkclients.Value{ + Scope: "/planes/radius/local/resourceGroups/default", + }, + } + providerConfig.Deployments = &sdkclients.Deployments{ + Type: "Microsoft.Resources", + Value: sdkclients.Value{ + Scope: "/planes/radius/local/resourceGroups/default", + }, + } + + marshalledProviderConfig, err := json.MarshalIndent(providerConfig, "", " ") + if err != nil { + return "", err + } + return string(marshalledProviderConfig), nil +} + +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"] + } + } +} diff --git a/test/functional-portable/kubernetes/noncloud/testdata/recipe/recipe.yaml b/test/functional-portable/kubernetes/noncloud/testdata/recipe/recipe.yaml new file mode 100644 index 0000000000..302e5cd9b3 --- /dev/null +++ b/test/functional-portable/kubernetes/noncloud/testdata/recipe/recipe.yaml @@ -0,0 +1,116 @@ +apiVersion: radapp.io/v1alpha3 +kind: DeploymentTemplate +metadata: + name: recipe.bicep +spec: + parameters: {} + 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": "7805117482855564", + "version": "0.31.92.45157" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "registry": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "resources": { + "app": { + "dependsOn": [ + "env" + ], + "import": "Radius", + "properties": { + "name": "[format('{0}-app', parameters('name'))]", + "properties": { + "environment": "[reference('env').id]", + "extensions": [ + { + "kind": "kubernetesNamespace", + "namespace": "[parameters('namespace')]" + } + ] + } + }, + "type": "Applications.Core/applications@2023-10-01-preview" + }, + "env": { + "import": "Radius", + "properties": { + "name": "[format('{0}-env', parameters('name'))]", + "properties": { + "compute": { + "kind": "kubernetes", + "namespace": "[format('{0}-env', parameters('name'))]", + "resourceId": "self" + }, + "recipes": { + "Applications.Datastores/redisCaches": { + "default": { + "templateKind": "bicep", + "templatePath": "[format('{0}/test/testrecipes/test-bicep-recipes/redis-recipe-value-backed:{1}', parameters('registry'), parameters('version'))]" + } + } + } + } + }, + "type": "Applications.Core/environments@2023-10-01-preview" + }, + "recipe": { + "dependsOn": [ + "app", + "env" + ], + "import": "Radius", + "properties": { + "name": "[format('{0}-recipe', parameters('name'))]", + "properties": { + "application": "[reference('app').id]", + "environment": "[reference('env').id]" + } + }, + "type": "Applications.Datastores/redisCaches@2023-10-01-preview" + } + } + }