diff --git a/go.mod b/go.mod index 65eaef97..608b5b40 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,8 @@ require ( github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/hcl/v2 v2.19.1 github.com/hashicorp/terraform-json v0.17.1 + github.com/hashicorp/terraform-plugin-framework v1.4.1 + github.com/hashicorp/terraform-plugin-go v0.19.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0 github.com/iancoleman/strcase v0.2.0 github.com/json-iterator/go v1.1.12 @@ -74,11 +76,14 @@ require ( github.com/google/uuid v1.3.0 // indirect github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-plugin v1.5.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect - github.com/hashicorp/terraform-plugin-go v0.19.0 // indirect github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect + github.com/hashicorp/terraform-registry-address v0.2.2 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -94,6 +99,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/run v1.0.0 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/prometheus/client_model v0.4.0 // indirect github.com/prometheus/common v0.44.0 // indirect @@ -116,6 +122,8 @@ require ( golang.org/x/time v0.3.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/grpc v1.58.3 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/apiextensions-apiserver v0.28.2 // indirect diff --git a/go.sum b/go.sum index 48362ec2..6915c4e9 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmms github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bufbuild/protocompile v0.6.0 h1:Uu7WiSQ6Yj9DbkdnOe7U4mNKp58y9WDMKDn28/ZlunY= +github.com/bufbuild/protocompile v0.6.0/go.mod h1:YNP35qEYoYGme7QMtz5SBCoN4kL4g12jTtjuzRNdjpE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -210,6 +212,8 @@ github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUK github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.5.1 h1:oGm7cWBaYIp3lJpx1RUEfLWophprE2EV/KUeqBYo+6k= +github.com/hashicorp/go-plugin v1.5.1/go.mod h1:w1sAEES3g3PuV/RzUrgow20W2uErMly84hhD3um1WL4= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= @@ -223,12 +227,20 @@ github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-json v0.17.1 h1:eMfvh/uWggKmY7Pmb3T85u86E2EQg6EQHgyRwf3RkyA= github.com/hashicorp/terraform-json v0.17.1/go.mod h1:Huy6zt6euxaY9knPAFKjUITn8QxUFIe9VuSzb4zn/0o= +github.com/hashicorp/terraform-plugin-framework v1.4.1 h1:ZC29MoB3Nbov6axHdgPbMz7799pT5H8kIrM8YAsaVrs= +github.com/hashicorp/terraform-plugin-framework v1.4.1/go.mod h1:XC0hPcQbBvlbxwmjxuV/8sn8SbZRg4XwGMs22f+kqV0= github.com/hashicorp/terraform-plugin-go v0.19.0 h1:BuZx/6Cp+lkmiG0cOBk6Zps0Cb2tmqQpDM3iAtnhDQU= github.com/hashicorp/terraform-plugin-go v0.19.0/go.mod h1:EhRSkEPNoylLQntYsk5KrDHTZJh9HQoumZXbOGOXmec= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0 h1:X7vB6vn5tON2b49ILa4W7mFAsndeqJ7bZFOGbVO+0Cc= github.com/hashicorp/terraform-plugin-sdk/v2 v2.30.0/go.mod h1:ydFcxbdj6klCqYEPkPvdvFKiNGKZLUs+896ODUXCyao= +github.com/hashicorp/terraform-registry-address v0.2.2 h1:lPQBg403El8PPicg/qONZJDC6YlgCVbWDtNmmZKtBno= +github.com/hashicorp/terraform-registry-address v0.2.2/go.mod h1:LtwNbCihUoUZ3RYriyS2wF/lGPB6gF9ICLRtuDk7hSo= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d h1:kJCB4vdITiW1eC1vq2e6IsrXKrZit1bv/TDYFGMp4BQ= +github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -236,6 +248,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1: github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -293,6 +307,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/muvaf/typewriter v0.0.0-20210910160850-80e49fe1eb32 h1:yBQlHXLeUJL3TWVmzup5uT3wG5FLxhiTAiTsmNVocys= github.com/muvaf/typewriter v0.0.0-20210910160850-80e49fe1eb32/go.mod h1:SAAdeMEiFXR8LcHffvIdiLI1w243DCH2DuHq7UrA5YQ= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU= github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= @@ -683,6 +699,8 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -699,6 +717,8 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/pkg/config/common.go b/pkg/config/common.go index eb5c0b8d..651a4c88 100644 --- a/pkg/config/common.go +++ b/pkg/config/common.go @@ -7,6 +7,7 @@ package config import ( "strings" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/crossplane/upjet/pkg/registry" @@ -56,7 +57,7 @@ type ResourceOption func(*Resource) // DefaultResource keeps an initial default configuration for all resources of a // provider. -func DefaultResource(name string, terraformSchema *schema.Resource, terraformRegistry *registry.Resource, opts ...ResourceOption) *Resource { +func DefaultResource(name string, terraformSchema *schema.Resource, terraformPluginFrameworkResource fwresource.Resource, terraformRegistry *registry.Resource, opts ...ResourceOption) *Resource { words := strings.Split(name, "_") // As group name we default to the second element if resource name // has at least 3 elements, otherwise, we took the first element as @@ -77,18 +78,19 @@ func DefaultResource(name string, terraformSchema *schema.Resource, terraformReg } r := &Resource{ - Name: name, - TerraformResource: terraformSchema, - MetaResource: terraformRegistry, - ShortGroup: group, - Kind: kind, - Version: "v1alpha1", - ExternalName: NameAsIdentifier, - References: make(References), - Sensitive: NopSensitive, - UseAsync: true, - SchemaElementOptions: make(SchemaElementOptions), - ServerSideApplyMergeStrategies: make(ServerSideApplyMergeStrategies), + Name: name, + TerraformResource: terraformSchema, + TerraformPluginFrameworkResource: terraformPluginFrameworkResource, + MetaResource: terraformRegistry, + ShortGroup: group, + Kind: kind, + Version: "v1alpha1", + ExternalName: NameAsIdentifier, + References: make(References), + Sensitive: NopSensitive, + UseAsync: true, + SchemaElementOptions: make(SchemaElementOptions), + ServerSideApplyMergeStrategies: make(ServerSideApplyMergeStrategies), } for _, f := range opts { f(r) diff --git a/pkg/config/common_test.go b/pkg/config/common_test.go index 4b8a7207..19578e13 100644 --- a/pkg/config/common_test.go +++ b/pkg/config/common_test.go @@ -9,6 +9,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/crossplane/upjet/pkg/registry" @@ -16,10 +17,11 @@ import ( func TestDefaultResource(t *testing.T) { type args struct { - name string - sch *schema.Resource - reg *registry.Resource - opts []ResourceOption + name string + sch *schema.Resource + frameworkResource fwresource.Resource + reg *registry.Resource + opts []ResourceOption } cases := map[string]struct { @@ -125,11 +127,12 @@ func TestDefaultResource(t *testing.T) { cmpopts.IgnoreFields(LateInitializer{}, "ignoredCanonicalFieldPaths"), cmpopts.IgnoreFields(ExternalName{}, "SetIdentifierArgumentFn", "GetExternalNameFn", "GetIDFn"), cmpopts.IgnoreFields(Resource{}, "useNoForkClient"), + cmpopts.IgnoreFields(Resource{}, "useTerraformPluginFrameworkClient"), } for name, tc := range cases { t.Run(name, func(t *testing.T) { - r := DefaultResource(tc.args.name, tc.args.sch, tc.args.reg, tc.args.opts...) + r := DefaultResource(tc.args.name, tc.args.sch, tc.args.frameworkResource, tc.args.reg, tc.args.opts...) if diff := cmp.Diff(tc.want, r, ignoreUnexported...); diff != "" { t.Errorf("\n%s\nDefaultResource(...): -want, +got:\n%s", tc.reason, diff) } diff --git a/pkg/config/provider.go b/pkg/config/provider.go index 0b31f3f3..6d0b71fc 100644 --- a/pkg/config/provider.go +++ b/pkg/config/provider.go @@ -5,10 +5,13 @@ package config import ( + "context" "fmt" "regexp" tfjson "github.com/hashicorp/terraform-json" + fwprovider "github.com/hashicorp/terraform-plugin-framework/provider" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/pkg/errors" @@ -115,22 +118,36 @@ type Provider struct { // Defaults to []string{".+"} which would include all resources. IncludeList []string - // NoForkIncludeList is a list of regex for the Terraform resources to be - // included and reconciled in the no-fork architecture (without the - // Terraform CLI). + // NoForkIncludeList is a list of regex for the Terraform resources + // implemented with Terraform Plugin SDKv2 to be included and reconciled + // in the no-fork architecture (without the Terraform CLI). // For example, to include "aws_shield_protection_group" into // the generated resources, one can add "aws_shield_protection_group$". // To include whole aws waf group, one can add "aws_waf.*" to the list. // Defaults to []string{".+"} which would include all resources. NoForkIncludeList []string + // TerraformPluginFrameworkIncludeList is a list of regex for the Terraform + // resources implemented with Terraform Plugin Framework to be included and + // reconciled in the no-fork architecture (without the Terraform CLI). + // For example, to include "aws_shield_protection_group" into + // the generated resources, one can add "aws_shield_protection_group$". + // To include whole aws waf group, one can add "aws_waf.*" to the list. + // Defaults to []string{".+"} which would include all resources. + TerraformPluginFrameworkIncludeList []string + // Resources is a map holding resource configurations where key is Terraform // resource name. Resources map[string]*Resource - // TerraformProvider is the Terraform schema of the provider. + // TerraformProvider is the Terraform provider in Terraform Plugin SDKv2 + // compatible format TerraformProvider *schema.Provider + // TerraformPluginFrameworkProvider is the Terraform provider reference + // in Terraform Plugin Framework compatible format + TerraformPluginFrameworkProvider fwprovider.Provider + // refInjectors is an ordered list of `ReferenceInjector`s for // injecting references across this Provider's resources. refInjectors []ReferenceInjector @@ -170,13 +187,23 @@ func WithIncludeList(l []string) ProviderOption { } } -// WithNoForkIncludeList configures IncludeList for this Provider. +// WithNoForkIncludeList configures the NoForkIncludeList for this Provider, +// with the given Terraform Plugin SDKv2-based resource name list func WithNoForkIncludeList(l []string) ProviderOption { return func(p *Provider) { p.NoForkIncludeList = l } } +// WithTerraformPluginFrameworkIncludeList configures the +// TerraformPluginFrameworkIncludeList for this Provider, with the given +// Terraform Plugin Framework-based resource name list +func WithTerraformPluginFrameworkIncludeList(l []string) ProviderOption { + return func(p *Provider) { + p.TerraformPluginFrameworkIncludeList = l + } +} + // WithTerraformProvider configures the TerraformProvider for this Provider. func WithTerraformProvider(tp *schema.Provider) ProviderOption { return func(p *Provider) { @@ -184,6 +211,14 @@ func WithTerraformProvider(tp *schema.Provider) ProviderOption { } } +// WithTerraformPluginFrameworkProvider configures the +// TerraformPluginFrameworkProvider for this Provider. +func WithTerraformPluginFrameworkProvider(tp fwprovider.Provider) ProviderOption { + return func(p *Provider) { + p.TerraformPluginFrameworkProvider = tp + } +} + // WithSkipList configures SkipList for this Provider. func WithSkipList(l []string) ProviderOption { return func(p *Provider) { @@ -269,6 +304,7 @@ func NewProvider(schema []byte, prefix string, modulePath string, metadata []byt } p.skippedResourceNames = make([]string, 0, len(resourceMap)) + terraformPluginFrameworkResourceFunctionsMap := terraformPluginFrameworkResourceFunctionsMap(p.TerraformPluginFrameworkProvider) for name, terraformResource := range resourceMap { if len(terraformResource.Schema) == 0 { // There are resources with no schema, that we will address later. @@ -276,7 +312,8 @@ func NewProvider(schema []byte, prefix string, modulePath string, metadata []byt } // if in both of the include lists, the new behavior prevails isNoFork := matches(name, p.NoForkIncludeList) - if len(terraformResource.Schema) == 0 || matches(name, p.SkipList) || (!matches(name, p.IncludeList) && !isNoFork) { + isPluginFrameworkResource := matches(name, p.TerraformPluginFrameworkIncludeList) + if len(terraformResource.Schema) == 0 || matches(name, p.SkipList) || (!matches(name, p.IncludeList) && !isNoFork && !isPluginFrameworkResource) { p.skippedResourceNames = append(p.skippedResourceNames, name) continue } @@ -295,8 +332,21 @@ func NewProvider(schema []byte, prefix string, modulePath string, metadata []byt terraformResource.Schema = terraformResource.SchemaFunc() } } - p.Resources[name] = DefaultResource(name, terraformResource, providerMetadata.Resources[name], p.DefaultResourceOptions...) + + var terraformPluginFrameworkResource fwresource.Resource + if isPluginFrameworkResource { + resourceFunc := terraformPluginFrameworkResourceFunctionsMap[name] + if p.TerraformPluginFrameworkProvider == nil || resourceFunc == nil { + panic(errors.Errorf("resource %q is configured to be reconciled with Terraform Plugin Framework"+ + "but either config.Provider.TerraformPluginFrameworkProvider is not configured or the provider doesn't have the resource.", name)) + } + + terraformPluginFrameworkResource = resourceFunc() + } + + p.Resources[name] = DefaultResource(name, terraformResource, terraformPluginFrameworkResource, providerMetadata.Resources[name], p.DefaultResourceOptions...) p.Resources[name].useNoForkClient = isNoFork + p.Resources[name].useTerraformPluginFrameworkClient = isPluginFrameworkResource } for i, refInjector := range p.refInjectors { if err := refInjector.InjectReferences(p.Resources); err != nil { @@ -351,3 +401,30 @@ func matches(name string, regexList []string) bool { } return false } + +func terraformPluginFrameworkResourceFunctionsMap(provider fwprovider.Provider) map[string]func() fwresource.Resource { + if provider == nil { + return make(map[string]func() fwresource.Resource, 0) + } + + ctx := context.TODO() + resourceFunctions := provider.Resources(ctx) + resourceFunctionsMap := make(map[string]func() fwresource.Resource, len(resourceFunctions)) + + providerMetadata := fwprovider.MetadataResponse{} + provider.Metadata(ctx, fwprovider.MetadataRequest{}, &providerMetadata) + + for _, resourceFunction := range resourceFunctions { + resource := resourceFunction() + + resourceTypeNameReq := fwresource.MetadataRequest{ + ProviderTypeName: providerMetadata.TypeName, + } + resourceTypeNameResp := fwresource.MetadataResponse{} + resource.Metadata(ctx, resourceTypeNameReq, &resourceTypeNameResp) + + resourceFunctionsMap[resourceTypeNameResp.TypeName] = resourceFunction + } + + return resourceFunctionsMap +} diff --git a/pkg/config/resource.go b/pkg/config/resource.go index c405c698..155210ca 100644 --- a/pkg/config/resource.go +++ b/pkg/config/resource.go @@ -13,6 +13,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/fieldpath" "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/pkg/errors" @@ -376,9 +377,14 @@ type Resource struct { // e.g. aws_rds_cluster. Name string - // TerraformResource is the Terraform representation of the resource. + // TerraformResource is the Terraform representation of the + // Terraform Plugin SDKv2 based resource. TerraformResource *schema.Resource + // TerraformPluginFrameworkResource is the Terraform representation + // of the TF Plugin Framework based resource + TerraformPluginFrameworkResource fwresource.Resource + // ShortGroup is the short name of the API group of this CRD. The full // CRD API group is calculated by adding the group suffix of the provider. // For example, ShortGroup could be `ec2` where group suffix of the @@ -453,6 +459,11 @@ type Resource struct { // be generated instead of the Terraform CLI-forking client. useNoForkClient bool + // useTerraformPluginFrameworkClient indicates that a Terraform + // Plugin Framework external client should be generated instead of + // the Terraform Plugin SDKv2 client. + useTerraformPluginFrameworkClient bool + // OverrideFieldNames allows to manually override the relevant field name to // avoid possible Go struct name conflicts that may occur after Multiversion // CRDs support. During field generation, there may be fields with the same @@ -480,10 +491,20 @@ type Resource struct { OverrideFieldNames map[string]string } +// ShouldUseNoForkClient returns whether to generate a SDKv2-based no-fork +// external client for this Resource, instead of the Terraform CLI-forking +// external client func (r *Resource) ShouldUseNoForkClient() bool { return r.useNoForkClient } +// ShouldUseTerraformPluginFrameworkClient returns whether to generate a +// Terraform Plugin Framework-based no-fork external client for this Resource +// instead of a Terraform Plugin SDKv2-based external client +func (r *Resource) ShouldUseTerraformPluginFrameworkClient() bool { + return r.useTerraformPluginFrameworkClient +} + // CustomDiff customizes the computed Terraform InstanceDiff. This can be used // in cases where, for example, changes in a certain argument should just be // dismissed. The new InstanceDiff is returned along with any errors. diff --git a/pkg/controller/external_async_terraform_plugin_framework.go b/pkg/controller/external_async_terraform_plugin_framework.go new file mode 100644 index 00000000..bd3fb9de --- /dev/null +++ b/pkg/controller/external_async_terraform_plugin_framework.go @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/crossplane/crossplane-runtime/pkg/meta" + "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" + xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/pkg/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/upjet/pkg/config" + "github.com/crossplane/upjet/pkg/controller/handler" + "github.com/crossplane/upjet/pkg/metrics" + "github.com/crossplane/upjet/pkg/resource" + "github.com/crossplane/upjet/pkg/terraform" + tferrors "github.com/crossplane/upjet/pkg/terraform/errors" +) + +type TerraformPluginFrameworkAsyncConnector struct { + *TerraformPluginFrameworkConnector + callback CallbackProvider + eventHandler *handler.EventHandler +} + +type TerraformPluginFrameworkAsyncOption func(connector *TerraformPluginFrameworkAsyncConnector) + +func NewTerraformPluginFrameworkAsyncConnector(kube client.Client, + ots *OperationTrackerStore, + sf terraform.SetupFn, + cfg *config.Resource, + opts ...TerraformPluginFrameworkAsyncOption) *TerraformPluginFrameworkAsyncConnector { + nfac := &TerraformPluginFrameworkAsyncConnector{ + TerraformPluginFrameworkConnector: NewTerraformPluginFrameworkConnector(kube, sf, cfg, ots), + } + for _, f := range opts { + f(nfac) + } + return nfac +} + +func (c *TerraformPluginFrameworkAsyncConnector) Connect(ctx context.Context, mg xpresource.Managed) (managed.ExternalClient, error) { + ec, err := c.TerraformPluginFrameworkConnector.Connect(ctx, mg) + if err != nil { + return nil, errors.Wrap(err, "cannot initialize the Terraform Plugin Framework async external client") + } + + return &terraformPluginFrameworkAsyncExternalClient{ + terraformPluginFrameworkExternalClient: ec.(*terraformPluginFrameworkExternalClient), + callback: c.callback, + eventHandler: c.eventHandler, + }, nil +} + +// WithTerraformPluginFrameworkAsyncConnectorEventHandler configures the EventHandler so that +// the Terraform Plugin Framework external clients can requeue reconciliation requests. +func WithTerraformPluginFrameworkAsyncConnectorEventHandler(e *handler.EventHandler) TerraformPluginFrameworkAsyncOption { + return func(c *TerraformPluginFrameworkAsyncConnector) { + c.eventHandler = e + } +} + +// WithTerraformPluginFrameworkAsyncCallbackProvider configures the controller to use async variant of the functions +// of the Terraform client and run given callbacks once those operations are +// completed. +func WithTerraformPluginFrameworkAsyncCallbackProvider(ac CallbackProvider) TerraformPluginFrameworkAsyncOption { + return func(c *TerraformPluginFrameworkAsyncConnector) { + c.callback = ac + } +} + +// WithTerraformPluginFrameworkAsyncLogger configures a logger for the TerraformPluginFrameworkAsyncConnector. +func WithTerraformPluginFrameworkAsyncLogger(l logging.Logger) TerraformPluginFrameworkAsyncOption { + return func(c *TerraformPluginFrameworkAsyncConnector) { + c.logger = l + } +} + +// WithTerraformPluginFrameworkAsyncMetricRecorder configures a metrics.MetricRecorder for the +// TerraformPluginFrameworkAsyncConnector. +func WithTerraformPluginFrameworkAsyncMetricRecorder(r *metrics.MetricRecorder) TerraformPluginFrameworkAsyncOption { + return func(c *TerraformPluginFrameworkAsyncConnector) { + c.metricRecorder = r + } +} + +// WithTerraformPluginFrameworkAsyncManagementPolicies configures whether the client should +// handle management policies. +func WithTerraformPluginFrameworkAsyncManagementPolicies(isManagementPoliciesEnabled bool) TerraformPluginFrameworkAsyncOption { + return func(c *TerraformPluginFrameworkAsyncConnector) { + c.isManagementPoliciesEnabled = isManagementPoliciesEnabled + } +} + +type terraformPluginFrameworkAsyncExternalClient struct { + *terraformPluginFrameworkExternalClient + callback CallbackProvider + eventHandler *handler.EventHandler +} + +func (n *terraformPluginFrameworkAsyncExternalClient) Observe(ctx context.Context, mg xpresource.Managed) (managed.ExternalObservation, error) { + if n.opTracker.LastOperation.IsRunning() { + n.logger.WithValues("opType", n.opTracker.LastOperation.Type).Debug("ongoing async operation") + return managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, nil + } + n.opTracker.LastOperation.Flush() + + o, err := n.terraformPluginFrameworkExternalClient.Observe(ctx, mg) + // clear any previously reported LastAsyncOperation error condition here, + // because there are no pending updates on the existing resource and it's + // not scheduled to be deleted. + if err == nil && o.ResourceExists && o.ResourceUpToDate && !meta.WasDeleted(mg) { + mg.(resource.Terraformed).SetConditions(resource.LastAsyncOperationCondition(nil)) + } + return o, err +} + +func (n *terraformPluginFrameworkAsyncExternalClient) Create(_ context.Context, mg xpresource.Managed) (managed.ExternalCreation, error) { + if !n.opTracker.LastOperation.MarkStart("create") { + return managed.ExternalCreation{}, errors.Errorf("%s operation that started at %s is still running", n.opTracker.LastOperation.Type, n.opTracker.LastOperation.StartTime().String()) + } + + ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(defaultAsyncTimeout)) + go func() { + defer cancel() + + n.opTracker.logger.Debug("Async create starting...") + _, err := n.terraformPluginFrameworkExternalClient.Create(ctx, mg) + err = tferrors.NewAsyncCreateFailed(err) + n.opTracker.LastOperation.SetError(err) + n.opTracker.logger.Debug("Async create ended.", "error", err) + + n.opTracker.LastOperation.MarkEnd() + if cErr := n.callback.Create(mg.GetName())(err, ctx); cErr != nil { + n.opTracker.logger.Info("Async create callback failed", "error", cErr.Error()) + } + }() + + return managed.ExternalCreation{}, nil +} + +func (n *terraformPluginFrameworkAsyncExternalClient) Update(_ context.Context, mg xpresource.Managed) (managed.ExternalUpdate, error) { + if !n.opTracker.LastOperation.MarkStart("update") { + return managed.ExternalUpdate{}, errors.Errorf("%s operation that started at %s is still running", n.opTracker.LastOperation.Type, n.opTracker.LastOperation.StartTime().String()) + } + + ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(defaultAsyncTimeout)) + go func() { + defer cancel() + + n.opTracker.logger.Debug("Async update starting...") + _, err := n.terraformPluginFrameworkExternalClient.Update(ctx, mg) + err = tferrors.NewAsyncUpdateFailed(err) + n.opTracker.LastOperation.SetError(err) + n.opTracker.logger.Debug("Async update ended.", "error", err) + + n.opTracker.LastOperation.MarkEnd() + if cErr := n.callback.Update(mg.GetName())(err, ctx); cErr != nil { + n.opTracker.logger.Info("Async update callback failed", "error", cErr.Error()) + } + }() + + return managed.ExternalUpdate{}, nil +} + +func (n *terraformPluginFrameworkAsyncExternalClient) Delete(_ context.Context, mg xpresource.Managed) error { + switch { + case n.opTracker.LastOperation.Type == "delete": + n.opTracker.logger.Debug("The previous delete operation is still ongoing") + return nil + case !n.opTracker.LastOperation.MarkStart("delete"): + return errors.Errorf("%s operation that started at %s is still running", n.opTracker.LastOperation.Type, n.opTracker.LastOperation.StartTime().String()) + } + + ctx, cancel := context.WithDeadline(context.Background(), n.opTracker.LastOperation.StartTime().Add(defaultAsyncTimeout)) + go func() { + defer cancel() + + n.opTracker.logger.Debug("Async delete starting...") + err := tferrors.NewAsyncDeleteFailed(n.terraformPluginFrameworkExternalClient.Delete(ctx, mg)) + n.opTracker.LastOperation.SetError(err) + n.opTracker.logger.Debug("Async delete ended.", "error", err) + + n.opTracker.LastOperation.MarkEnd() + if cErr := n.callback.Destroy(mg.GetName())(err, ctx); cErr != nil { + n.opTracker.logger.Info("Async delete callback failed", "error", cErr.Error()) + } + }() + + return nil +} diff --git a/pkg/controller/external_nofork.go b/pkg/controller/external_nofork.go index 7e8cfab7..1d753c66 100644 --- a/pkg/controller/external_nofork.go +++ b/pkg/controller/external_nofork.go @@ -146,8 +146,10 @@ func getExtendedParameters(ctx context.Context, tr resource.Terraformed, externa // not all providers may have this attribute // TODO: tags-tags_all implementation is AWS specific. // Consider making this logic independent of provider. - if _, ok := config.TerraformResource.CoreConfigSchema().Attributes["tags_all"]; ok { - params["tags_all"] = params["tags"] + if config.TerraformResource != nil { + if _, ok := config.TerraformResource.CoreConfigSchema().Attributes["tags_all"]; ok { + params["tags_all"] = params["tags"] + } } return params, nil } diff --git a/pkg/controller/external_terraform_plugin_framework.go b/pkg/controller/external_terraform_plugin_framework.go new file mode 100644 index 00000000..edf44e6f --- /dev/null +++ b/pkg/controller/external_terraform_plugin_framework.go @@ -0,0 +1,683 @@ +// SPDX-FileCopyrightText: 2024 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "encoding/json" + "fmt" + "math" + "math/big" + "strings" + "time" + + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/crossplane/crossplane-runtime/pkg/meta" + "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" + xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" + fwdiag "github.com/hashicorp/terraform-plugin-framework/diag" + fwprovider "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/upjet/pkg/config" + "github.com/crossplane/upjet/pkg/metrics" + "github.com/crossplane/upjet/pkg/resource" + upjson "github.com/crossplane/upjet/pkg/resource/json" + "github.com/crossplane/upjet/pkg/terraform" +) + +// TerraformPluginFrameworkConnector is an external client, with credentials and +// other configuration parameters, for Terraform Plugin Framework resources. You +// can use NewTerraformPluginFrameworkConnector to construct. +type TerraformPluginFrameworkConnector struct { + getTerraformSetup terraform.SetupFn + kube client.Client + config *config.Resource + logger logging.Logger + metricRecorder *metrics.MetricRecorder + operationTrackerStore *OperationTrackerStore + isManagementPoliciesEnabled bool +} + +// TerraformPluginFrameworkConnectorOption allows you to configure TerraformPluginFrameworkConnector. +type TerraformPluginFrameworkConnectorOption func(connector *TerraformPluginFrameworkConnector) + +// WithTerraformPluginFrameworkLogger configures a logger for the TerraformPluginFrameworkConnector. +func WithTerraformPluginFrameworkLogger(l logging.Logger) TerraformPluginFrameworkConnectorOption { + return func(c *TerraformPluginFrameworkConnector) { + c.logger = l + } +} + +// WithTerraformPluginFrameworkMetricRecorder configures a metrics.MetricRecorder for the +// TerraformPluginFrameworkConnectorOption. +func WithTerraformPluginFrameworkMetricRecorder(r *metrics.MetricRecorder) TerraformPluginFrameworkConnectorOption { + return func(c *TerraformPluginFrameworkConnector) { + c.metricRecorder = r + } +} + +// WithTerraformPluginFrameworkManagementPolicies configures whether the client should +// handle management policies. +func WithTerraformPluginFrameworkManagementPolicies(isManagementPoliciesEnabled bool) TerraformPluginFrameworkConnectorOption { + return func(c *TerraformPluginFrameworkConnector) { + c.isManagementPoliciesEnabled = isManagementPoliciesEnabled + } +} + +// NewTerraformPluginFrameworkConnector creates a new +// TerraformPluginFrameworkConnector with given options. +func NewTerraformPluginFrameworkConnector(kube client.Client, sf terraform.SetupFn, cfg *config.Resource, ots *OperationTrackerStore, opts ...TerraformPluginFrameworkConnectorOption) *TerraformPluginFrameworkConnector { + connector := &TerraformPluginFrameworkConnector{ + getTerraformSetup: sf, + kube: kube, + config: cfg, + operationTrackerStore: ots, + } + for _, f := range opts { + f(connector) + } + return connector +} + +type terraformPluginFrameworkExternalClient struct { + ts terraform.Setup + config *config.Resource + logger logging.Logger + metricRecorder *metrics.MetricRecorder + opTracker *AsyncTracker + resource fwresource.Resource + server tfprotov5.ProviderServer + params map[string]any + plannedState *tfprotov5.DynamicValue + resourceSchema rschema.Schema + // the terraform value type associated with the resource schema + resourceValueTerraformType tftypes.Type +} + +// Connect makes sure the underlying client is ready to issue requests to the +// provider API. +func (c *TerraformPluginFrameworkConnector) Connect(ctx context.Context, mg xpresource.Managed) (managed.ExternalClient, error) { //nolint:gocyclo + c.metricRecorder.ObserveReconcileDelay(mg.GetObjectKind().GroupVersionKind(), mg.GetName()) + logger := c.logger.WithValues("uid", mg.GetUID(), "name", mg.GetName(), "gvk", mg.GetObjectKind().GroupVersionKind().String()) + logger.Debug("Connecting to the service provider") + start := time.Now() + ts, err := c.getTerraformSetup(ctx, c.kube, mg) + metrics.ExternalAPITime.WithLabelValues("connect").Observe(time.Since(start).Seconds()) + if err != nil { + return nil, errors.Wrap(err, errGetTerraformSetup) + } + + tr := mg.(resource.Terraformed) + opTracker := c.operationTrackerStore.Tracker(tr) + externalName := meta.GetExternalName(tr) + params, err := getExtendedParameters(ctx, tr, externalName, c.config, ts, c.isManagementPoliciesEnabled, c.kube) + if err != nil { + return nil, errors.Wrapf(err, "failed to get the extended parameters for resource %q", mg.GetName()) + } + + resourceSchema, err := c.getResourceSchema(ctx) + if err != nil { + return nil, errors.Wrap(err, "could not retrieve resource schema") + } + resourceTfValueType := resourceSchema.Type().TerraformType(ctx) + hasState := false + if opTracker.HasFrameworkTFState() { + tfStateValue, err := opTracker.GetFrameworkTFState().Unmarshal(resourceTfValueType) + if err != nil { + return nil, errors.Wrap(err, "cannot unmarshal TF state dynamic value during state existence check") + } + hasState = err == nil && !tfStateValue.IsNull() + } + + if !hasState { + logger.Debug("Instance state not found in cache, reconstructing...") + tfState, err := tr.GetObservation() + if err != nil { + return nil, errors.Wrap(err, "failed to get the observation") + } + copyParams := len(tfState) == 0 + if err = resource.GetSensitiveParameters(ctx, &APISecretClient{kube: c.kube}, tr, tfState, tr.GetConnectionDetailsMapping()); err != nil { + return nil, errors.Wrap(err, "cannot store sensitive parameters into tfState") + } + c.config.ExternalName.SetIdentifierArgumentFn(tfState, externalName) + tfState["id"] = params["id"] + if copyParams { + tfState = copyParameters(tfState, params) + } + + tfStateDynamicValue, err := protov5DynamicValueFromMap(tfState, resourceTfValueType) + if err != nil { + return nil, errors.Wrap(err, "cannot construct dynamic value for TF state") + } + opTracker.SetFrameworkTFState(tfStateDynamicValue) + } + + configuredProviderServer, err := c.configureProvider(ctx, ts) + if err != nil { + return nil, errors.Wrap(err, "could not configure provider server") + } + + return &terraformPluginFrameworkExternalClient{ + ts: ts, + config: c.config, + logger: logger, + metricRecorder: c.metricRecorder, + opTracker: opTracker, + resource: c.config.TerraformPluginFrameworkResource, + server: configuredProviderServer, + params: params, + resourceSchema: resourceSchema, + resourceValueTerraformType: resourceTfValueType, + }, nil +} + +// getResourceSchema returns the Terraform Plugin Framework-style resource schema for the configured framework resource on the connector +func (c *TerraformPluginFrameworkConnector) getResourceSchema(ctx context.Context) (rschema.Schema, error) { + res := c.config.TerraformPluginFrameworkResource + schemaResp := &fwresource.SchemaResponse{} + res.Schema(ctx, fwresource.SchemaRequest{}, schemaResp) + if schemaResp.Diagnostics.HasError() { + fwErrors := frameworkDiagnosticsToString(schemaResp.Diagnostics) + return rschema.Schema{}, errors.Errorf("could not retrieve resource schema: %s", fwErrors) + } + + return schemaResp.Schema, nil +} + +// configureProvider returns a configured Terraform protocol v5 provider server +// with the preconfigured provider instance in the terraform setup. +// The provider instance used should be already preconfigured +// at the terraform setup layer with the relevant provider meta if needed +// by the provider implementation. +func (c *TerraformPluginFrameworkConnector) configureProvider(ctx context.Context, ts terraform.Setup) (tfprotov5.ProviderServer, error) { + var schemaResp fwprovider.SchemaResponse + ts.FrameworkProvider.Schema(ctx, fwprovider.SchemaRequest{}, &schemaResp) + if schemaResp.Diagnostics.HasError() { + fwDiags := frameworkDiagnosticsToString(schemaResp.Diagnostics) + return nil, fmt.Errorf("cannot retrieve provider schema: %s", fwDiags) + } + providerServer := providerserver.NewProtocol5(ts.FrameworkProvider)() + + providerConfigDynamicVal, err := protov5DynamicValueFromMap(ts.Configuration, schemaResp.Schema.Type().TerraformType(ctx)) + if err != nil { + return nil, errors.Wrap(err, "cannot construct dynamic value for TF provider config") + } + + configureProviderReq := &tfprotov5.ConfigureProviderRequest{ + TerraformVersion: "crossTF000", + Config: providerConfigDynamicVal, + } + providerResp, err := providerServer.ConfigureProvider(ctx, configureProviderReq) + if err != nil { + return nil, errors.Wrap(err, "cannot configure framework provider") + } + if fatalDiags := getFatalDiagnostics(providerResp.Diagnostics); fatalDiags != nil { + return nil, errors.Wrap(fatalDiags, "provider configure request failed") + } + return providerServer, nil +} + +// getDiffPlan calls the underlying native TF provider's PlanResourceChange RPC, +// and returns the planned state and whether a diff exists. +// If plan response contains non-empty RequiresReplace (i.e. the resource needs +// to be recreated) an error is returned as Crossplane Resource Model (XRM) +// prohibits resource re-creations and rejects this plan. +func (n *terraformPluginFrameworkExternalClient) getDiffPlan(ctx context.Context, + tfStateValue tftypes.Value) (*tfprotov5.DynamicValue, bool, error) { + tfConfigDynamicVal, err := protov5DynamicValueFromMap(n.params, n.resourceValueTerraformType) + if err != nil { + return &tfprotov5.DynamicValue{}, false, errors.Wrap(err, "cannot construct dynamic value for TF Config") + } + + // + tfPlannedStateDynamicVal, err := protov5DynamicValueFromMap(n.params, n.resourceValueTerraformType) + if err != nil { + return &tfprotov5.DynamicValue{}, false, errors.Wrap(err, "cannot construct dynamic value for TF Planned State") + } + + prcReq := &tfprotov5.PlanResourceChangeRequest{ + TypeName: n.config.Name, + PriorState: n.opTracker.GetFrameworkTFState(), + Config: tfConfigDynamicVal, + ProposedNewState: tfPlannedStateDynamicVal, + } + planResponse, err := n.server.PlanResourceChange(ctx, prcReq) + if err != nil { + return &tfprotov5.DynamicValue{}, false, errors.Wrap(err, "cannot plan change") + } + if fatalDiags := getFatalDiagnostics(planResponse.Diagnostics); fatalDiags != nil { + return &tfprotov5.DynamicValue{}, false, errors.Wrap(fatalDiags, "plan resource change request failed") + } + + if len(planResponse.RequiresReplace) > 0 { + var sb strings.Builder + sb.WriteString("diff contains fields that require resource replacement: ") + for _, attrPath := range planResponse.RequiresReplace { + sb.WriteString(attrPath.String()) + sb.WriteString(", ") + } + return nil, false, errors.New(sb.String()) + } + + plannedStateValue, err := planResponse.PlannedState.Unmarshal(n.resourceValueTerraformType) + if err != nil { + return nil, false, errors.Wrap(err, "cannot unmarshal planned state") + } + + diffso, err := plannedStateValue.Diff(tfStateValue) + if err != nil { + return nil, false, errors.Wrap(err, "cannot compare prior state and plan") + } + + return planResponse.PlannedState, len(diffso) > 0, nil +} + +func (n *terraformPluginFrameworkExternalClient) Observe(ctx context.Context, mg xpresource.Managed) (managed.ExternalObservation, error) { //nolint:gocyclo + n.logger.Debug("Observing the external resource") + + if meta.WasDeleted(mg) && n.opTracker.IsDeleted() { + return managed.ExternalObservation{ + ResourceExists: false, + }, nil + } + + readRequest := &tfprotov5.ReadResourceRequest{ + TypeName: n.config.Name, + CurrentState: n.opTracker.GetFrameworkTFState(), + } + readResponse, err := n.server.ReadResource(ctx, readRequest) + + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, "cannot read resource") + } + + if fatalDiags := getFatalDiagnostics(readResponse.Diagnostics); fatalDiags != nil { + return managed.ExternalObservation{}, errors.Wrap(fatalDiags, "read resource request failed") + } + + tfStateValue, err := readResponse.NewState.Unmarshal(n.resourceValueTerraformType) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, "cannot unmarshal state value") + } + + n.opTracker.SetFrameworkTFState(readResponse.NewState) + resourceExists := !tfStateValue.IsNull() + + var stateValueMap map[string]any + if resourceExists { + if conv, err := tfValueToGoValue(tfStateValue); err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, "cannot convert instance state to JSON map") + } else { + stateValueMap = conv.(map[string]any) + } + } + + plannedState, hasDiff, err := n.getDiffPlan(ctx, tfStateValue) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, "cannot calculate diff") + } + + n.plannedState = plannedState + + var connDetails managed.ConnectionDetails + if !resourceExists && mg.GetDeletionTimestamp() != nil { + gvk := mg.GetObjectKind().GroupVersionKind() + metrics.DeletionTime.WithLabelValues(gvk.Group, gvk.Version, gvk.Kind).Observe(time.Since(mg.GetDeletionTimestamp().Time).Seconds()) + } + + specUpdateRequired := false + if resourceExists { + if mg.GetCondition(xpv1.TypeReady).Status == corev1.ConditionUnknown || + mg.GetCondition(xpv1.TypeReady).Status == corev1.ConditionFalse { + addTTR(mg) + } + mg.SetConditions(xpv1.Available()) + buff, err := upjson.TFParser.Marshal(stateValueMap) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, "cannot marshal the attributes of the new state for late-initialization") + } + specUpdateRequired, err = mg.(resource.Terraformed).LateInitialize(buff) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, "cannot late-initialize the managed resource") + } + err = mg.(resource.Terraformed).SetObservation(stateValueMap) + if err != nil { + return managed.ExternalObservation{}, errors.Errorf("could not set observation: %v", err) + } + connDetails, err = resource.GetConnectionDetails(stateValueMap, mg.(resource.Terraformed), n.config) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, "cannot get connection details") + } + if !hasDiff { + n.metricRecorder.SetReconcileTime(mg.GetName()) + } + if !specUpdateRequired { + resource.SetUpToDateCondition(mg, !hasDiff) + } + if nameChanged, err := n.setExternalName(mg, stateValueMap); err != nil { + return managed.ExternalObservation{}, errors.Wrapf(err, "failed to set the external-name of the managed resource during observe") + } else { + specUpdateRequired = specUpdateRequired || nameChanged + } + } + + return managed.ExternalObservation{ + ResourceExists: resourceExists, + ResourceUpToDate: !hasDiff, + ConnectionDetails: connDetails, + ResourceLateInitialized: specUpdateRequired, + }, nil +} + +func (n *terraformPluginFrameworkExternalClient) Create(ctx context.Context, mg xpresource.Managed) (managed.ExternalCreation, error) { + n.logger.Debug("Creating the external resource") + + tfConfigDynamicVal, err := protov5DynamicValueFromMap(n.params, n.resourceValueTerraformType) + if err != nil { + return managed.ExternalCreation{}, errors.Wrap(err, "cannot construct dynamic value for TF Config") + } + + applyRequest := &tfprotov5.ApplyResourceChangeRequest{ + TypeName: n.config.Name, + PriorState: n.opTracker.GetFrameworkTFState(), + PlannedState: n.plannedState, + Config: tfConfigDynamicVal, + } + start := time.Now() + applyResponse, err := n.server.ApplyResourceChange(ctx, applyRequest) + if err != nil { + return managed.ExternalCreation{}, errors.Wrap(err, "cannot create resource") + } + metrics.ExternalAPITime.WithLabelValues("create").Observe(time.Since(start).Seconds()) + if fatalDiags := getFatalDiagnostics(applyResponse.Diagnostics); fatalDiags != nil { + return managed.ExternalCreation{}, errors.Wrap(fatalDiags, "resource creation call returned error diags") + } + + newStateAfterApplyVal, err := applyResponse.NewState.Unmarshal(n.resourceValueTerraformType) + if err != nil { + return managed.ExternalCreation{}, errors.Wrap(err, "cannot unmarshal planned state") + } + + if newStateAfterApplyVal.IsNull() { + return managed.ExternalCreation{}, errors.New("new state is empty after creation") + } + + var stateValueMap map[string]any + if goval, err := tfValueToGoValue(newStateAfterApplyVal); err != nil { + return managed.ExternalCreation{}, errors.New("cannot convert native state to go map") + } else { + stateValueMap = goval.(map[string]any) + } + + n.opTracker.SetFrameworkTFState(applyResponse.NewState) + + if _, err := n.setExternalName(mg, stateValueMap); err != nil { + return managed.ExternalCreation{}, errors.Wrapf(err, "failed to set the external-name of the managed resource during create") + } + + err = mg.(resource.Terraformed).SetObservation(stateValueMap) + if err != nil { + return managed.ExternalCreation{}, errors.Errorf("could not set observation: %v", err) + } + + conn, err := resource.GetConnectionDetails(stateValueMap, mg.(resource.Terraformed), n.config) + if err != nil { + return managed.ExternalCreation{}, errors.Wrap(err, "cannot get connection details") + } + + return managed.ExternalCreation{ConnectionDetails: conn}, nil +} + +func (n *terraformPluginFrameworkExternalClient) Update(ctx context.Context, mg xpresource.Managed) (managed.ExternalUpdate, error) { + n.logger.Debug("Updating the external resource") + + tfConfigDynamicVal, err := protov5DynamicValueFromMap(n.params, n.resourceValueTerraformType) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, "cannot construct dynamic value for TF Config") + } + + applyRequest := &tfprotov5.ApplyResourceChangeRequest{ + TypeName: n.config.Name, + PriorState: n.opTracker.GetFrameworkTFState(), + PlannedState: n.plannedState, + Config: tfConfigDynamicVal, + } + start := time.Now() + applyResponse, err := n.server.ApplyResourceChange(ctx, applyRequest) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, "cannot update resource") + } + metrics.ExternalAPITime.WithLabelValues("update").Observe(time.Since(start).Seconds()) + if fatalDiags := getFatalDiagnostics(applyResponse.Diagnostics); fatalDiags != nil { + return managed.ExternalUpdate{}, errors.Wrap(fatalDiags, "resource update call returned error diags") + } + n.opTracker.SetFrameworkTFState(applyResponse.NewState) + + newStateAfterApplyVal, err := applyResponse.NewState.Unmarshal(n.resourceValueTerraformType) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, "cannot unmarshal updated state") + } + + if newStateAfterApplyVal.IsNull() { + return managed.ExternalUpdate{}, errors.New("new state is empty after update") + } + + var stateValueMap map[string]any + if goval, err := tfValueToGoValue(newStateAfterApplyVal); err != nil { + return managed.ExternalUpdate{}, errors.New("cannot convert native state to go map") + } else { + stateValueMap = goval.(map[string]any) + } + + err = mg.(resource.Terraformed).SetObservation(stateValueMap) + if err != nil { + return managed.ExternalUpdate{}, errors.Errorf("could not set observation: %v", err) + } + + return managed.ExternalUpdate{}, nil +} + +func (n *terraformPluginFrameworkExternalClient) Delete(ctx context.Context, _ xpresource.Managed) error { + n.logger.Debug("Deleting the external resource") + + tfConfigDynamicVal, err := protov5DynamicValueFromMap(n.params, n.resourceValueTerraformType) + if err != nil { + return errors.Wrap(err, "cannot construct dynamic value for TF Config") + } + // set an empty planned state, this corresponds to deleting + plannedState, err := tfprotov5.NewDynamicValue(n.resourceValueTerraformType, tftypes.NewValue(n.resourceValueTerraformType, nil)) + if err != nil { + return errors.Wrap(err, "cannot set the planned state for deletion") + } + + applyRequest := &tfprotov5.ApplyResourceChangeRequest{ + TypeName: n.config.Name, + PriorState: n.opTracker.GetFrameworkTFState(), + PlannedState: &plannedState, + Config: tfConfigDynamicVal, + } + start := time.Now() + applyResponse, err := n.server.ApplyResourceChange(ctx, applyRequest) + if err != nil { + return errors.Wrap(err, "cannot delete resource") + } + metrics.ExternalAPITime.WithLabelValues("delete").Observe(time.Since(start).Seconds()) + if fatalDiags := getFatalDiagnostics(applyResponse.Diagnostics); fatalDiags != nil { + return errors.Wrap(fatalDiags, "resource deletion call returned error diags") + } + n.opTracker.SetFrameworkTFState(applyResponse.NewState) + + newStateAfterApplyVal, err := applyResponse.NewState.Unmarshal(n.resourceValueTerraformType) + if err != nil { + return errors.Wrap(err, "cannot unmarshal state after deletion") + } + // mark the resource as logically deleted if the TF call clears the state + n.opTracker.SetDeleted(newStateAfterApplyVal.IsNull()) + + return nil +} + +func (n *terraformPluginFrameworkExternalClient) setExternalName(mg xpresource.Managed, stateValueMap map[string]interface{}) (bool, error) { + id, ok := stateValueMap["id"] + if !ok || id.(string) == "" { + return false, nil + } + newName, err := n.config.ExternalName.GetExternalNameFn(stateValueMap) + if err != nil { + return false, errors.Wrapf(err, "failed to compute the external-name from the state map of the resource with the ID %s", id) + } + oldName := meta.GetExternalName(mg) + // we have to make sure the newly set external-name is recorded + meta.SetExternalName(mg, newName) + return oldName != newName, nil +} + +// tfValueToGoValue converts a given tftypes.Value to Go-native any type. +// Useful for converting terraform values of state to JSON or for setting +// observations at the MR. +// Nested values are recursively converted. +// Supported conversions: +// tftypes.Object, tftypes.Map => map[string]any +// tftypes.Set, tftypes.List, tftypes.Tuple => []string +// tftypes.Bool => bool +// tftypes.Number => int64, float64 +// tftypes.String => string +// tftypes.DynamicPseudoType => conversion not supported and returns an error +func tfValueToGoValue(input tftypes.Value) (any, error) { //nolint:gocyclo + if !input.IsKnown() { + return nil, fmt.Errorf("cannot convert unknown value") + } + if input.IsNull() { + return nil, nil + } + valType := input.Type() + switch { + case valType.Is(tftypes.Object{}), valType.Is(tftypes.Map{}): + destInterim := make(map[string]tftypes.Value) + dest := make(map[string]any) + if err := input.As(&destInterim); err != nil { + return nil, err + } + for k, v := range destInterim { + res, err := tfValueToGoValue(v) + if err != nil { + return nil, err + } + dest[k] = res + + } + return dest, nil + case valType.Is(tftypes.Set{}), valType.Is(tftypes.List{}), valType.Is(tftypes.Tuple{}): + destInterim := make([]tftypes.Value, 0) + if err := input.As(&destInterim); err != nil { + return nil, err + } + dest := make([]any, len(destInterim)) + for i, v := range destInterim { + res, err := tfValueToGoValue(v) + if err != nil { + return nil, err + } + dest[i] = res + } + return dest, nil + case valType.Is(tftypes.Bool): + var x bool + return x, input.As(&x) + case valType.Is(tftypes.Number): + var valBigF big.Float + if err := input.As(&valBigF); err != nil { + return nil, err + } + // try to parse as integer + if valBigF.IsInt() { + intVal, accuracy := valBigF.Int64() + if accuracy != 0 { + return nil, fmt.Errorf("value %v cannot be represented as a 64-bit integer", valBigF) + } + return intVal, nil + } + // try to parse as float64 + xf, accuracy := valBigF.Float64() + // Underflow + // Reference: https://pkg.go.dev/math/big#Float.Float64 + if xf == 0 && accuracy != big.Exact { + return nil, fmt.Errorf("value %v cannot be represented as a 64-bit floating point", valBigF) + } + + // Overflow + // Reference: https://pkg.go.dev/math/big#Float.Float64 + if math.IsInf(xf, 0) { + return nil, fmt.Errorf("value %v cannot be represented as a 64-bit floating point", valBigF) + } + return xf, nil + + case valType.Is(tftypes.String): + var x string + return x, input.As(&x) + case valType.Is(tftypes.DynamicPseudoType): + return nil, errors.New("DynamicPseudoType conversion is not supported") + default: + return nil, fmt.Errorf("input value has unknown type: %s", valType.String()) + } +} + +// getFatalDiagnostics traverses the given Terraform protov5 diagnostics type +// and constructs a Go error. If the provided diag slice is empty, returns nil. +func getFatalDiagnostics(diags []*tfprotov5.Diagnostic) error { + var errs error + var diagErrors []string + for _, tfdiag := range diags { + if tfdiag.Severity == tfprotov5.DiagnosticSeverityInvalid || tfdiag.Severity == tfprotov5.DiagnosticSeverityError { + diagErrors = append(diagErrors, fmt.Sprintf("%s: %s", tfdiag.Summary, tfdiag.Detail)) + } + } + if len(diagErrors) > 0 { + errs = errors.New(strings.Join(diagErrors, "\n")) + } + return errs +} + +// frameworkDiagnosticsToString constructs an error string from the provided +// Plugin Framework diagnostics instance. Only Error severity diagnostics are +// included. +func frameworkDiagnosticsToString(fwdiags fwdiag.Diagnostics) string { + frameworkErrorDiags := fwdiags.Errors() + diagErrors := make([]string, 0, len(frameworkErrorDiags)) + for _, tfdiag := range frameworkErrorDiags { + diagErrors = append(diagErrors, fmt.Sprintf("%s: %s", tfdiag.Summary(), tfdiag.Detail())) + } + return strings.Join(diagErrors, "\n") +} + +// protov5DynamicValueFromMap constructs a protov5 DynamicValue given the +// map[string]any using the terraform type as reference. +func protov5DynamicValueFromMap(data map[string]any, terraformType tftypes.Type) (*tfprotov5.DynamicValue, error) { + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, errors.Wrap(err, "cannot marshal json") + } + + tfValue, err := tftypes.ValueFromJSONWithOpts(jsonBytes, terraformType, tftypes.ValueFromJSONOpts{IgnoreUndefinedAttributes: true}) + if err != nil { + return nil, errors.Wrap(err, "cannot construct tf value from json") + } + + dynamicValue, err := tfprotov5.NewDynamicValue(terraformType, tfValue) + if err != nil { + return nil, errors.Wrap(err, "cannot construct dynamic value from tf value") + } + + return &dynamicValue, nil +} diff --git a/pkg/controller/external_terraform_plugin_framework_test.go b/pkg/controller/external_terraform_plugin_framework_test.go new file mode 100644 index 00000000..4465a2bf --- /dev/null +++ b/pkg/controller/external_terraform_plugin_framework_test.go @@ -0,0 +1,722 @@ +// SPDX-FileCopyrightText: 2024 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "testing" + + "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" + xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/crossplane/crossplane-runtime/pkg/test" + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/pkg/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/upjet/pkg/config" + "github.com/crossplane/upjet/pkg/resource/fake" + "github.com/crossplane/upjet/pkg/terraform" +) + +func newBaseObject() *fake.Terraformed { + return &fake.Terraformed{ + Parameterizable: fake.Parameterizable{ + Parameters: map[string]any{ + "name": "example", + "map": map[string]any{ + "key": "value", + }, + "list": []any{"elem1", "elem2"}, + }, + }, + Observable: fake.Observable{ + Observation: map[string]any{}, + }, + } +} + +func newBaseSchema() rschema.Schema { + return rschema.Schema{ + Attributes: map[string]rschema.Attribute{ + "name": rschema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "id": rschema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "map": rschema.MapAttribute{ + Required: true, + ElementType: types.StringType, + }, + "list": rschema.ListAttribute{ + Required: true, + ElementType: types.StringType, + }, + }, + } +} + +func newMockBaseTPFResource() *mockTPFResource { + return &mockTPFResource{ + SchemaMethod: func(ctx context.Context, request resource.SchemaRequest, response *resource.SchemaResponse) { + response.Schema = newBaseSchema() + }, + ReadMethod: func(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { + response.State = tfsdk.State{ + Raw: tftypes.Value{}, + Schema: nil, + } + }, + } +} + +func newBaseUpjetConfig() *config.Resource { + return &config.Resource{ + TerraformPluginFrameworkResource: newMockBaseTPFResource(), + ExternalName: config.IdentifierFromProvider, + Sensitive: config.Sensitive{AdditionalConnectionDetailsFn: func(attr map[string]any) (map[string][]byte, error) { + return nil, nil + }}, + } +} + +type testConfiguration struct { + r resource.Resource + cfg *config.Resource + obj xpresource.Managed + params map[string]any + currentStateMap map[string]any + plannedStateMap map[string]any + newStateMap map[string]any + + readErr error + readDiags []*tfprotov5.Diagnostic + + applyErr error + applyDiags []*tfprotov5.Diagnostic + + planErr error + planDiags []*tfprotov5.Diagnostic +} + +func prepareTPFExternalWithTestConfig(testConfig testConfiguration) *terraformPluginFrameworkExternalClient { + testConfig.cfg.TerraformPluginFrameworkResource = testConfig.r + schemaResp := &resource.SchemaResponse{} + testConfig.r.Schema(context.TODO(), resource.SchemaRequest{}, schemaResp) + tfValueType := schemaResp.Schema.Type().TerraformType(context.TODO()) + + currentStateVal, err := protov5DynamicValueFromMap(testConfig.currentStateMap, tfValueType) + if err != nil { + panic("cannot prepare TPF") + } + plannedStateVal, err := protov5DynamicValueFromMap(testConfig.plannedStateMap, tfValueType) + if err != nil { + panic("cannot prepare TPF") + } + newStateAfterApplyVal, err := protov5DynamicValueFromMap(testConfig.newStateMap, tfValueType) + if err != nil { + panic("cannot prepare TPF") + } + return &terraformPluginFrameworkExternalClient{ + ts: terraform.Setup{ + FrameworkProvider: &mockTPFProvider{}, + }, + config: cfg, + logger: logTest, + // metricRecorder: nil, + opTracker: NewAsyncTracker(), + resource: testConfig.r, + server: &mockTPFProviderServer{ + ReadResourceFn: func(ctx context.Context, request *tfprotov5.ReadResourceRequest) (*tfprotov5.ReadResourceResponse, error) { + return &tfprotov5.ReadResourceResponse{ + NewState: currentStateVal, + Diagnostics: testConfig.readDiags, + }, testConfig.readErr + }, + PlanResourceChangeFn: func(ctx context.Context, request *tfprotov5.PlanResourceChangeRequest) (*tfprotov5.PlanResourceChangeResponse, error) { + return &tfprotov5.PlanResourceChangeResponse{ + PlannedState: plannedStateVal, + Diagnostics: testConfig.planDiags, + }, testConfig.planErr + }, + ApplyResourceChangeFn: func(ctx context.Context, request *tfprotov5.ApplyResourceChangeRequest) (*tfprotov5.ApplyResourceChangeResponse, error) { + return &tfprotov5.ApplyResourceChangeResponse{ + NewState: newStateAfterApplyVal, + Diagnostics: testConfig.applyDiags, + }, testConfig.applyErr + }, + }, + params: testConfig.params, + plannedState: plannedStateVal, + resourceSchema: schemaResp.Schema, + resourceValueTerraformType: tfValueType, + } +} + +func TestTPFConnect(t *testing.T) { + type args struct { + setupFn terraform.SetupFn + cfg *config.Resource + ots *OperationTrackerStore + obj xpresource.Managed + } + type want struct { + err error + } + cases := map[string]struct { + args + want + }{ + "Successful": { + args: args{ + setupFn: func(_ context.Context, _ client.Client, _ xpresource.Managed) (terraform.Setup, error) { + return terraform.Setup{ + FrameworkProvider: &mockTPFProvider{}, + }, nil + }, + cfg: newBaseUpjetConfig(), + obj: newBaseObject(), + ots: ots, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := NewTerraformPluginFrameworkConnector(nil, tc.args.setupFn, tc.args.cfg, tc.args.ots, WithTerraformPluginFrameworkLogger(logTest)) + _, err := c.Connect(context.TODO(), tc.args.obj) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nConnect(...): -want error, +got error:\n", diff) + } + }) + } +} + +func TestTPFObserve(t *testing.T) { + type want struct { + obs managed.ExternalObservation + err error + } + cases := map[string]struct { + testConfiguration + want + }{ + "NotExists": { + testConfiguration: testConfiguration{ + r: newMockBaseTPFResource(), + cfg: newBaseUpjetConfig(), + obj: obj, + currentStateMap: nil, + plannedStateMap: map[string]any{ + "name": "example", + }, + params: map[string]any{ + "name": "example", + }, + }, + want: want{ + obs: managed.ExternalObservation{ + ResourceExists: false, + ResourceUpToDate: false, + ResourceLateInitialized: false, + ConnectionDetails: nil, + Diff: "", + }, + }, + }, + + "UpToDate": { + testConfiguration: testConfiguration{ + r: newMockBaseTPFResource(), + cfg: newBaseUpjetConfig(), + obj: newBaseObject(), + params: map[string]any{ + "id": "example-id", + "name": "example", + }, + currentStateMap: map[string]any{ + "id": "example-id", + "name": "example", + }, + plannedStateMap: map[string]any{ + "id": "example-id", + "name": "example", + }, + }, + want: want{ + obs: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + ResourceLateInitialized: true, + ConnectionDetails: nil, + Diff: "", + }, + }, + }, + + "LateInitialize": { + testConfiguration: testConfiguration{ + r: newMockBaseTPFResource(), + cfg: newBaseUpjetConfig(), + obj: &fake.Terraformed{ + Parameterizable: fake.Parameterizable{ + Parameters: map[string]any{ + "name": "example", + "map": map[string]any{ + "key": "value", + }, + "list": []any{"elem1", "elem2"}, + }, + InitParameters: map[string]any{ + "list": []any{"elem1", "elem2", "elem3"}, + }, + }, + Observable: fake.Observable{ + Observation: map[string]any{}, + }, + }, + params: map[string]any{ + "id": "example-id", + }, + currentStateMap: map[string]any{ + "id": "example-id", + "name": "example2", + }, + plannedStateMap: map[string]any{ + "id": "example-id", + "name": "example2", + }, + }, + want: want{ + obs: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + ResourceLateInitialized: true, + ConnectionDetails: nil, + Diff: "", + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + tpfExternal := prepareTPFExternalWithTestConfig(tc.testConfiguration) + observation, err := tpfExternal.Observe(context.TODO(), tc.testConfiguration.obj) + if diff := cmp.Diff(tc.want.obs, observation); diff != "" { + t.Errorf("\n%s\nObserve(...): -want observation, +got observation:\n", diff) + } + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nConnect(...): -want error, +got error:\n", diff) + } + }) + } +} + +func TestTPFCreate(t *testing.T) { + type want struct { + err error + } + cases := map[string]struct { + testConfiguration + want + }{ + "Successful": { + testConfiguration: testConfiguration{ + r: newMockBaseTPFResource(), + cfg: newBaseUpjetConfig(), + obj: obj, + currentStateMap: nil, + plannedStateMap: map[string]any{ + "name": "example", + }, + params: map[string]any{ + "name": "example", + }, + newStateMap: map[string]any{ + "name": "example", + "id": "example-id", + }, + }, + }, + "EmptyStateAfterCreation": { + testConfiguration: testConfiguration{ + r: newMockBaseTPFResource(), + cfg: newBaseUpjetConfig(), + obj: obj, + currentStateMap: nil, + plannedStateMap: map[string]any{ + "name": "example", + }, + params: map[string]any{ + "name": "example", + }, + newStateMap: nil, + }, + want: want{ + err: errors.New("new state is empty after creation"), + }, + }, + "ApplyWithError": { + testConfiguration: testConfiguration{ + r: newMockBaseTPFResource(), + cfg: newBaseUpjetConfig(), + obj: obj, + currentStateMap: nil, + plannedStateMap: map[string]any{ + "name": "example", + }, + params: map[string]any{ + "name": "example", + }, + newStateMap: nil, + applyErr: errors.New("foo error"), + }, + want: want{ + err: errors.Wrap(errors.New("foo error"), "cannot create resource"), + }, + }, + "ApplyWithDiags": { + testConfiguration: testConfiguration{ + r: newMockBaseTPFResource(), + cfg: newBaseUpjetConfig(), + obj: obj, + currentStateMap: nil, + plannedStateMap: map[string]any{ + "name": "example", + }, + params: map[string]any{ + "name": "example", + }, + newStateMap: nil, + applyDiags: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "foo summary", + Detail: "foo detail", + }, + }, + }, + want: want{ + err: errors.Wrap(errors.New("foo summary: foo detail"), "resource creation call returned error diags"), + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + tpfExternal := prepareTPFExternalWithTestConfig(tc.testConfiguration) + _, err := tpfExternal.Create(context.TODO(), tc.testConfiguration.obj) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nConnect(...): -want error, +got error:\n", diff) + } + }) + } +} + +func TestTPFUpdate(t *testing.T) { + type want struct { + err error + } + cases := map[string]struct { + testConfiguration + want + }{ + "Successful": { + testConfiguration: testConfiguration{ + r: newMockBaseTPFResource(), + cfg: newBaseUpjetConfig(), + obj: newBaseObject(), + currentStateMap: map[string]any{ + "name": "example", + "id": "example-id", + }, + plannedStateMap: map[string]any{ + "name": "example-updated", + "id": "example-id", + }, + params: map[string]any{ + "name": "example-updated", + }, + newStateMap: map[string]any{ + "name": "example-updated", + "id": "example-id", + }, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + tpfExternal := prepareTPFExternalWithTestConfig(tc.testConfiguration) + _, err := tpfExternal.Update(context.TODO(), tc.testConfiguration.obj) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nConnect(...): -want error, +got error:\n", diff) + } + }) + } +} + +func TestTPFDelete(t *testing.T) { + + type want struct { + err error + } + cases := map[string]struct { + testConfiguration + want + }{ + "Successful": { + testConfiguration: testConfiguration{ + r: newMockBaseTPFResource(), + cfg: newBaseUpjetConfig(), + obj: newBaseObject(), + currentStateMap: map[string]any{ + "name": "example", + "id": "example-id", + }, + plannedStateMap: nil, + params: map[string]any{ + "name": "example", + }, + newStateMap: nil, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + tpfExternal := prepareTPFExternalWithTestConfig(tc.testConfiguration) + err := tpfExternal.Delete(context.TODO(), tc.testConfiguration.obj) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nConnect(...): -want error, +got error:\n", diff) + } + }) + } +} + +// Mocks + +var _ resource.Resource = &mockTPFResource{} +var _ tfprotov5.ProviderServer = &mockTPFProviderServer{} +var _ provider.Provider = &mockTPFProvider{} + +type mockTPFProviderServer struct { + GetMetadataFn func(ctx context.Context, request *tfprotov5.GetMetadataRequest) (*tfprotov5.GetMetadataResponse, error) + GetProviderSchemaFn func(ctx context.Context, request *tfprotov5.GetProviderSchemaRequest) (*tfprotov5.GetProviderSchemaResponse, error) + PrepareProviderConfigFn func(ctx context.Context, request *tfprotov5.PrepareProviderConfigRequest) (*tfprotov5.PrepareProviderConfigResponse, error) + ConfigureProviderFn func(ctx context.Context, request *tfprotov5.ConfigureProviderRequest) (*tfprotov5.ConfigureProviderResponse, error) + StopProviderFn func(ctx context.Context, request *tfprotov5.StopProviderRequest) (*tfprotov5.StopProviderResponse, error) + ValidateResourceTypeConfigFn func(ctx context.Context, request *tfprotov5.ValidateResourceTypeConfigRequest) (*tfprotov5.ValidateResourceTypeConfigResponse, error) + UpgradeResourceStateFn func(ctx context.Context, request *tfprotov5.UpgradeResourceStateRequest) (*tfprotov5.UpgradeResourceStateResponse, error) + ReadResourceFn func(ctx context.Context, request *tfprotov5.ReadResourceRequest) (*tfprotov5.ReadResourceResponse, error) + PlanResourceChangeFn func(ctx context.Context, request *tfprotov5.PlanResourceChangeRequest) (*tfprotov5.PlanResourceChangeResponse, error) + ApplyResourceChangeFn func(ctx context.Context, request *tfprotov5.ApplyResourceChangeRequest) (*tfprotov5.ApplyResourceChangeResponse, error) + ImportResourceStateFn func(ctx context.Context, request *tfprotov5.ImportResourceStateRequest) (*tfprotov5.ImportResourceStateResponse, error) + ValidateDataSourceConfigFn func(ctx context.Context, request *tfprotov5.ValidateDataSourceConfigRequest) (*tfprotov5.ValidateDataSourceConfigResponse, error) + ReadDataSourceFn func(ctx context.Context, request *tfprotov5.ReadDataSourceRequest) (*tfprotov5.ReadDataSourceResponse, error) +} + +func (m *mockTPFProviderServer) GetMetadata(ctx context.Context, request *tfprotov5.GetMetadataRequest) (*tfprotov5.GetMetadataResponse, error) { + // TODO implement me + panic("implement me") +} + +func (m *mockTPFProviderServer) GetProviderSchema(ctx context.Context, request *tfprotov5.GetProviderSchemaRequest) (*tfprotov5.GetProviderSchemaResponse, error) { + // TODO implement me + panic("implement me") +} + +func (m *mockTPFProviderServer) PrepareProviderConfig(ctx context.Context, request *tfprotov5.PrepareProviderConfigRequest) (*tfprotov5.PrepareProviderConfigResponse, error) { + // TODO implement me + panic("implement me") +} + +func (m *mockTPFProviderServer) ConfigureProvider(ctx context.Context, request *tfprotov5.ConfigureProviderRequest) (*tfprotov5.ConfigureProviderResponse, error) { + // TODO implement me + panic("implement me") +} + +func (m *mockTPFProviderServer) StopProvider(ctx context.Context, request *tfprotov5.StopProviderRequest) (*tfprotov5.StopProviderResponse, error) { + // TODO implement me + panic("implement me") +} + +func (m *mockTPFProviderServer) ValidateResourceTypeConfig(ctx context.Context, request *tfprotov5.ValidateResourceTypeConfigRequest) (*tfprotov5.ValidateResourceTypeConfigResponse, error) { + // TODO implement me + panic("implement me") +} + +func (m *mockTPFProviderServer) UpgradeResourceState(ctx context.Context, request *tfprotov5.UpgradeResourceStateRequest) (*tfprotov5.UpgradeResourceStateResponse, error) { + // TODO implement me + panic("implement me") +} + +func (m *mockTPFProviderServer) ReadResource(ctx context.Context, request *tfprotov5.ReadResourceRequest) (*tfprotov5.ReadResourceResponse, error) { + if m.ReadResourceFn == nil { + return nil, nil + } + return m.ReadResourceFn(ctx, request) +} + +func (m *mockTPFProviderServer) PlanResourceChange(ctx context.Context, request *tfprotov5.PlanResourceChangeRequest) (*tfprotov5.PlanResourceChangeResponse, error) { + if m.PlanResourceChangeFn == nil { + return nil, nil + } + return m.PlanResourceChangeFn(ctx, request) +} + +func (m *mockTPFProviderServer) ApplyResourceChange(ctx context.Context, request *tfprotov5.ApplyResourceChangeRequest) (*tfprotov5.ApplyResourceChangeResponse, error) { + if m.ApplyResourceChangeFn == nil { + return nil, nil + } + return m.ApplyResourceChangeFn(ctx, request) +} + +func (m *mockTPFProviderServer) ImportResourceState(ctx context.Context, request *tfprotov5.ImportResourceStateRequest) (*tfprotov5.ImportResourceStateResponse, error) { + // TODO implement me + panic("implement me") +} + +func (m *mockTPFProviderServer) ValidateDataSourceConfig(ctx context.Context, request *tfprotov5.ValidateDataSourceConfigRequest) (*tfprotov5.ValidateDataSourceConfigResponse, error) { + // TODO implement me + panic("implement me") +} + +func (m *mockTPFProviderServer) ReadDataSource(ctx context.Context, request *tfprotov5.ReadDataSourceRequest) (*tfprotov5.ReadDataSourceResponse, error) { + // TODO implement me + panic("implement me") +} + +type mockTPFProvider struct { + // Provider interface methods + MetadataMethod func(context.Context, provider.MetadataRequest, *provider.MetadataResponse) + ConfigureMethod func(context.Context, provider.ConfigureRequest, *provider.ConfigureResponse) + SchemaMethod func(context.Context, provider.SchemaRequest, *provider.SchemaResponse) + DataSourcesMethod func(context.Context) []func() datasource.DataSource + ResourcesMethod func(context.Context) []func() resource.Resource +} + +// Configure satisfies the provider.Provider interface. +func (p *mockTPFProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + if p == nil || p.ConfigureMethod == nil { + return + } + + p.ConfigureMethod(ctx, req, resp) +} + +// DataSources satisfies the provider.Provider interface. +func (p *mockTPFProvider) DataSources(ctx context.Context) []func() datasource.DataSource { + if p == nil || p.DataSourcesMethod == nil { + return nil + } + + return p.DataSourcesMethod(ctx) +} + +// Metadata satisfies the provider.Provider interface. +func (p *mockTPFProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + if p == nil || p.MetadataMethod == nil { + return + } + + p.MetadataMethod(ctx, req, resp) +} + +// Schema satisfies the provider.Provider interface. +func (p *mockTPFProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { + if p == nil || p.SchemaMethod == nil { + return + } + + p.SchemaMethod(ctx, req, resp) +} + +// Resources satisfies the provider.Provider interface. +func (p *mockTPFProvider) Resources(ctx context.Context) []func() resource.Resource { + if p == nil || p.ResourcesMethod == nil { + return nil + } + + return p.ResourcesMethod(ctx) +} + +type mockTPFResource struct { + // Resource interface methods + MetadataMethod func(context.Context, resource.MetadataRequest, *resource.MetadataResponse) + SchemaMethod func(context.Context, resource.SchemaRequest, *resource.SchemaResponse) + CreateMethod func(context.Context, resource.CreateRequest, *resource.CreateResponse) + DeleteMethod func(context.Context, resource.DeleteRequest, *resource.DeleteResponse) + ReadMethod func(context.Context, resource.ReadRequest, *resource.ReadResponse) + UpdateMethod func(context.Context, resource.UpdateRequest, *resource.UpdateResponse) +} + +// Metadata satisfies the resource.Resource interface. +func (r *mockTPFResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + if r.MetadataMethod == nil { + return + } + + r.MetadataMethod(ctx, req, resp) +} + +// Schema satisfies the resource.Resource interface. +func (r *mockTPFResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + if r.SchemaMethod == nil { + return + } + + r.SchemaMethod(ctx, req, resp) +} + +// Create satisfies the resource.Resource interface. +func (r *mockTPFResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + if r.CreateMethod == nil { + return + } + + r.CreateMethod(ctx, req, resp) +} + +// Delete satisfies the resource.Resource interface. +func (r *mockTPFResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + if r.DeleteMethod == nil { + return + } + + r.DeleteMethod(ctx, req, resp) +} + +// Read satisfies the resource.Resource interface. +func (r *mockTPFResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + if r.ReadMethod == nil { + return + } + + r.ReadMethod(ctx, req, resp) +} + +// Update satisfies the resource.Resource interface. +func (r *mockTPFResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + if r.UpdateMethod == nil { + return + } + + r.UpdateMethod(ctx, req, resp) +} diff --git a/pkg/controller/external_test.go b/pkg/controller/external_test.go index 68e06c16..4b919ddc 100644 --- a/pkg/controller/external_test.go +++ b/pkg/controller/external_test.go @@ -592,7 +592,7 @@ func TestObserve(t *testing.T) { } for name, tc := range cases { t.Run(name, func(t *testing.T) { - e := &external{workspace: tc.w, config: config.DefaultResource("upjet_resource", nil, nil), kube: tc.args.client, logger: logging.NewNopLogger()} + e := &external{workspace: tc.w, config: config.DefaultResource("upjet_resource", nil, nil, nil), kube: tc.args.client, logger: logging.NewNopLogger()} observation, err := e.Observe(context.TODO(), tc.args.obj) if diff := cmp.Diff(tc.want.obs, observation); diff != "" { t.Errorf("\n%s\nObserve(...): -want observation, +got observation:\n%s", tc.reason, diff) diff --git a/pkg/controller/nofork_store.go b/pkg/controller/nofork_store.go index 4ec2e082..7be3807f 100644 --- a/pkg/controller/nofork_store.go +++ b/pkg/controller/nofork_store.go @@ -10,6 +10,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/logging" xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" tfsdk "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "k8s.io/apimachinery/pkg/types" @@ -17,11 +18,36 @@ import ( "github.com/crossplane/upjet/pkg/terraform" ) +// AsyncTracker holds information for a managed resource to track the +// async Terraform operations and the +// Terraform state (TF SDKv2 or TF Plugin Framework) of the external resource +// +// The typical usage is to instantiate an AsyncTracker for a managed resource, +// and store in a global OperationTrackerStore, to carry information between +// reconciliation scopes. +// +// When an asynchronous Terraform operation is started for the resource +// in a reconciliation (e.g. with a goroutine), consumers can mark an operation start +// on the LastOperation field, then access the operation status in the +// forthcoming reconciliation cycles, and act upon +// (e.g. hold further actions if there is an ongoing operation, mark the end +// when underlying Terraform operation is completed, save the resulting +// terraform state etc.) +// +// When utilized without the LastOperation usage, it can act as a Terraform +// state cache for synchronous reconciliations type AsyncTracker struct { + // LastOperation holds information about the most recent operation. + // Consumers are responsible for managing the last operation by starting, + // ending and flushing it when done with processing the results. + // Designed to allow only one ongoing operation at a given time. LastOperation *terraform.Operation logger logging.Logger mu *sync.Mutex - tfState *tfsdk.InstanceState + // TF Plugin SDKv2 instance state for TF Plugin SDKv2-based resources + tfState *tfsdk.InstanceState + // TF Plugin Framework instance state for TF Plugin Framework-based resources + fwState *tfprotov5.DynamicValue // lifecycle of certain external resources are bound to a parent resource's // lifecycle, and they cannot be deleted without actually deleting // the owning external resource (e.g., a database resource as the parent @@ -42,6 +68,8 @@ func WithAsyncTrackerLogger(l logging.Logger) AsyncTrackerOption { w.logger = l } } + +// NewAsyncTracker initializes an AsyncTracker with given options func NewAsyncTracker(opts ...AsyncTrackerOption) *AsyncTracker { w := &AsyncTracker{ LastOperation: &terraform.Operation{}, @@ -54,24 +82,35 @@ func NewAsyncTracker(opts ...AsyncTrackerOption) *AsyncTracker { return w } +// GetTfState returns the stored Terraform Plugin SDKv2 InstanceState for +// SDKv2 Terraform resources +// MUST be only used for SDKv2 resources. func (a *AsyncTracker) GetTfState() *tfsdk.InstanceState { a.mu.Lock() defer a.mu.Unlock() return a.tfState } +// HasState returns whether the AsyncTracker has a SDKv2 state stored. +// MUST be only used for SDKv2 resources. func (a *AsyncTracker) HasState() bool { a.mu.Lock() defer a.mu.Unlock() return a.tfState != nil && a.tfState.ID != "" } +// SetTfState stores the given SDKv2 Terraform InstanceState into +// the AsyncTracker +// MUST be only used for SDKv2 resources. func (a *AsyncTracker) SetTfState(state *tfsdk.InstanceState) { a.mu.Lock() defer a.mu.Unlock() a.tfState = state } +// GetTfID returns the Terraform ID of the external resource currently +// stored in this AsyncTracker's SDKv2 instance state. +// MUST be only used for SDKv2 resources. func (a *AsyncTracker) GetTfID() string { a.mu.Lock() defer a.mu.Unlock() @@ -93,12 +132,42 @@ func (a *AsyncTracker) SetDeleted(deleted bool) { a.isDeleted.Store(deleted) } +// GetFrameworkTFState returns the stored Terraform Plugin Framework external +// resource state in this AsyncTracker as *tfprotov5.DynamicValue +// MUST be used only for Terraform Plugin Framework resources +func (a *AsyncTracker) GetFrameworkTFState() *tfprotov5.DynamicValue { + a.mu.Lock() + defer a.mu.Unlock() + return a.fwState +} + +// HasFrameworkTFState returns whether this AsyncTracker has a +// Terraform Plugin Framework state stored. +// MUST be used only for Terraform Plugin Framework resources +func (a *AsyncTracker) HasFrameworkTFState() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.fwState != nil +} + +// SetFrameworkTFState stores the given *tfprotov5.DynamicValue Terraform Plugin Framework external +// resource state into this AsyncTracker's fwstate +// MUST be used only for Terraform Plugin Framework resources +func (a *AsyncTracker) SetFrameworkTFState(state *tfprotov5.DynamicValue) { + a.mu.Lock() + defer a.mu.Unlock() + a.fwState = state +} + +// OperationTrackerStore stores the AsyncTracker instances associated with the +// managed resource instance. type OperationTrackerStore struct { store map[types.UID]*AsyncTracker logger logging.Logger mu *sync.Mutex } +// NewOperationStore returns a new OperationTrackerStore instance func NewOperationStore(l logging.Logger) *OperationTrackerStore { ops := &OperationTrackerStore{ store: map[types.UID]*AsyncTracker{}, @@ -109,18 +178,26 @@ func NewOperationStore(l logging.Logger) *OperationTrackerStore { return ops } +// Tracker returns the associated *AsyncTracker stored in this +// OperationTrackerStore for the given managed resource. +// If there is no tracker stored previously, a new AsyncTracker is created and +// stored for the specified managed resource. Subsequent calls with the same managed +// resource will return the previously instantiated and stored AsyncTracker +// for that managed resource func (ops *OperationTrackerStore) Tracker(tr resource.Terraformed) *AsyncTracker { ops.mu.Lock() defer ops.mu.Unlock() tracker, ok := ops.store[tr.GetUID()] if !ok { - l := ops.logger.WithValues("trackerUID", tr.GetUID(), "resourceName", tr.GetName()) + l := ops.logger.WithValues("trackerUID", tr.GetUID(), "resourceName", tr.GetName(), "gvk", tr.GetObjectKind().GroupVersionKind().String()) ops.store[tr.GetUID()] = NewAsyncTracker(WithAsyncTrackerLogger(l)) tracker = ops.store[tr.GetUID()] } return tracker } +// RemoveTracker will remove the stored AsyncTracker of the given managed +// resource from this OperationTrackerStore. func (ops *OperationTrackerStore) RemoveTracker(obj xpresource.Object) error { ops.mu.Lock() defer ops.mu.Unlock() diff --git a/pkg/pipeline/controller.go b/pkg/pipeline/controller.go index 40a7c2d0..cdd0339a 100644 --- a/pkg/pipeline/controller.go +++ b/pkg/pipeline/controller.go @@ -47,12 +47,13 @@ func (cg *ControllerGenerator) Generate(cfg *config.Resource, typesPkgPath strin "CRD": map[string]string{ "Kind": cfg.Kind, }, - "DisableNameInitializer": cfg.ExternalName.DisableNameInitializer, - "TypePackageAlias": ctrlFile.Imports.UsePackage(typesPkgPath), - "UseAsync": cfg.UseAsync, - "UseNoForkClient": cfg.ShouldUseNoForkClient(), - "ResourceType": cfg.Name, - "Initializers": cfg.InitializerFns, + "DisableNameInitializer": cfg.ExternalName.DisableNameInitializer, + "TypePackageAlias": ctrlFile.Imports.UsePackage(typesPkgPath), + "UseAsync": cfg.UseAsync, + "UseNoForkClient": cfg.ShouldUseNoForkClient(), + "UseTerraformPluginFrameworkClient": cfg.ShouldUseTerraformPluginFrameworkClient(), + "ResourceType": cfg.Name, + "Initializers": cfg.InitializerFns, } // If the provider has a features package, add it to the controller template. diff --git a/pkg/pipeline/templates/controller.go.tmpl b/pkg/pipeline/templates/controller.go.tmpl index 5b584dc2..dfa651e2 100644 --- a/pkg/pipeline/templates/controller.go.tmpl +++ b/pkg/pipeline/templates/controller.go.tmpl @@ -43,7 +43,7 @@ func Setup(mgr ctrl.Manager, o tjcontroller.Options) error { } eventHandler := handler.NewEventHandler(handler.WithLogger(o.Logger.WithValues("gvk", {{ .TypePackageAlias }}{{ .CRD.Kind }}_GroupVersionKind))) {{- if .UseAsync }} - ac := tjcontroller.NewAPICallbacks(mgr, xpresource.ManagedKind({{ .TypePackageAlias }}{{ .CRD.Kind }}_GroupVersionKind), tjcontroller.WithEventHandler(eventHandler){{ if .UseNoForkClient }}, tjcontroller.WithStatusUpdates(false){{ end }}) + ac := tjcontroller.NewAPICallbacks(mgr, xpresource.ManagedKind({{ .TypePackageAlias }}{{ .CRD.Kind }}_GroupVersionKind), tjcontroller.WithEventHandler(eventHandler){{ if or .UseNoForkClient .UseTerraformPluginFrameworkClient }}, tjcontroller.WithStatusUpdates(false){{ end }}) {{- end}} opts := []managed.ReconcilerOption{ managed.WithExternalConnecter( @@ -67,12 +67,32 @@ func Setup(mgr ctrl.Manager, o tjcontroller.Options) error { {{- end -}} ) {{- end -}} + {{- else if .UseTerraformPluginFrameworkClient -}} + {{- if .UseAsync }} + tjcontroller.NewTerraformPluginFrameworkAsyncConnector(mgr.GetClient(), o.OperationTrackerStore, o.SetupFn, o.Provider.Resources["{{ .ResourceType }}"], + tjcontroller.WithTerraformPluginFrameworkAsyncLogger(o.Logger), + tjcontroller.WithTerraformPluginFrameworkAsyncConnectorEventHandler(eventHandler), + tjcontroller.WithTerraformPluginFrameworkAsyncCallbackProvider(ac), + tjcontroller.WithTerraformPluginFrameworkAsyncMetricRecorder(metrics.NewMetricRecorder({{ .TypePackageAlias }}{{ .CRD.Kind }}_GroupVersionKind, mgr, o.PollInterval)), + {{if .FeaturesPackageAlias -}} + tjcontroller.WithTerraformPluginFrameworkAsyncManagementPolicies(o.Features.Enabled({{ .FeaturesPackageAlias }}EnableBetaManagementPolicies)) + {{- end -}} + ) + {{- else }} + tjcontroller.NewTerraformPluginFrameworkConnector(mgr.GetClient(), o.SetupFn, o.Provider.Resources["{{ .ResourceType }}"], o.OperationTrackerStore, + tjcontroller.WithTerraformPluginFrameworkLogger(o.Logger), + tjcontroller.WithTerraformPluginFrameworkMetricRecorder(metrics.NewMetricRecorder({{ .TypePackageAlias }}{{ .CRD.Kind }}_GroupVersionKind, mgr, o.PollInterval)), + {{if .FeaturesPackageAlias -}} + tjcontroller.WithTerraformPluginFrameworkManagementPolicies(o.Features.Enabled({{ .FeaturesPackageAlias }}EnableBetaManagementPolicies)) + {{- end -}} + ) + {{- end }} {{- else -}} - tjcontroller.NewConnector(mgr.GetClient(), o.WorkspaceStore, o.SetupFn, o.Provider.Resources["{{ .ResourceType }}"], tjcontroller.WithLogger(o.Logger), tjcontroller.WithConnectorEventHandler(eventHandler), + tjcontroller.NewConnector(mgr.GetClient(), o.WorkspaceStore, o.SetupFn, o.Provider.Resources["{{ .ResourceType }}"], tjcontroller.WithLogger(o.Logger), tjcontroller.WithConnectorEventHandler(eventHandler), {{- if .UseAsync }} tjcontroller.WithCallbackProvider(ac), {{- end }} - ) + ) {{- end -}} ), managed.WithLogger(o.Logger.WithValues("controller", name)), diff --git a/pkg/resource/sensitive_test.go b/pkg/resource/sensitive_test.go index de2aabc8..faf72c27 100644 --- a/pkg/resource/sensitive_test.go +++ b/pkg/resource/sensitive_test.go @@ -110,7 +110,7 @@ func TestGetConnectionDetails(t *testing.T) { "NoConnectionDetails": { args: args{ tr: &fake.Terraformed{}, - cfg: config.DefaultResource("upjet_resource", nil, nil), + cfg: config.DefaultResource("upjet_resource", nil, nil, nil), }, }, "OnlyDefaultConnectionDetails": { @@ -122,7 +122,7 @@ func TestGetConnectionDetails(t *testing.T) { }, }, }, - cfg: config.DefaultResource("upjet_resource", nil, nil), + cfg: config.DefaultResource("upjet_resource", nil, nil, nil), data: map[string]any{ "top_level_secret": "sensitive-data-top-level-secret", }, @@ -142,7 +142,7 @@ func TestGetConnectionDetails(t *testing.T) { }, }, }, - cfg: config.DefaultResource("upjet_resource", nil, nil), + cfg: config.DefaultResource("upjet_resource", nil, nil, nil), data: map[string]any{ "top_level_secrets": []any{ "val1", @@ -168,7 +168,7 @@ func TestGetConnectionDetails(t *testing.T) { }, }, }, - cfg: config.DefaultResource("upjet_resource", nil, nil), + cfg: config.DefaultResource("upjet_resource", nil, nil, nil), data: map[string]any{ "top_level_secrets": map[string]any{ "key1": "val1", diff --git a/pkg/terraform/files_test.go b/pkg/terraform/files_test.go index 926ac1f2..ba86a300 100644 --- a/pkg/terraform/files_test.go +++ b/pkg/terraform/files_test.go @@ -67,7 +67,7 @@ func TestEnsureTFState(t *testing.T) { "obs": "obsval", }}, }, - cfg: config.DefaultResource("upjet_resource", nil, nil), + cfg: config.DefaultResource("upjet_resource", nil, nil, nil), fs: func() afero.Afero { return afero.Afero{Fs: afero.NewMemMapFs()} }, @@ -95,7 +95,7 @@ func TestEnsureTFState(t *testing.T) { "obs": "obsval", }}, }, - cfg: config.DefaultResource("upjet_resource", nil, nil, func(r *config.Resource) { + cfg: config.DefaultResource("upjet_resource", nil, nil, nil, func(r *config.Resource) { r.OperationTimeouts.Read = 2 * time.Minute }), fs: func() afero.Afero { @@ -126,7 +126,7 @@ func TestEnsureTFState(t *testing.T) { "obs": "obsval", }}, }, - cfg: config.DefaultResource("upjet_resource", nil, nil), + cfg: config.DefaultResource("upjet_resource", nil, nil, nil), fs: func() afero.Afero { fss := afero.Afero{Fs: afero.NewMemMapFs()} _ = fss.WriteFile(filepath.Join(dir, "terraform.tfstate"), []byte(empty), 0600) @@ -278,7 +278,7 @@ func TestIsStateEmpty(t *testing.T) { Parameterizable: fake.Parameterizable{Parameters: map[string]any{}}, }, Setup{}, - config.DefaultResource("upjet_resource", nil, nil), WithFileSystem(tc.args.fs()), + config.DefaultResource("upjet_resource", nil, nil, nil), WithFileSystem(tc.args.fs()), ) empty, err := fp.isStateEmpty() if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { @@ -326,7 +326,7 @@ func TestWriteMainTF(t *testing.T) { "obs": "obsval", }}, }, - cfg: config.DefaultResource("upjet_resource", nil, nil, func(r *config.Resource) { + cfg: config.DefaultResource("upjet_resource", nil, nil, nil, func(r *config.Resource) { r.OperationTimeouts = config.OperationTimeouts{ Read: 30 * time.Second, Update: 2 * time.Minute, @@ -363,7 +363,7 @@ func TestWriteMainTF(t *testing.T) { "obs": "obsval", }}, }, - cfg: config.DefaultResource("upjet_resource", nil, nil), + cfg: config.DefaultResource("upjet_resource", nil, nil, nil), s: Setup{ Requirement: ProviderRequirement{ Source: "hashicorp/provider-test", @@ -395,7 +395,7 @@ func TestWriteMainTF(t *testing.T) { "obs": "obsval", }}, }, - cfg: config.DefaultResource("upjet_resource", nil, nil), + cfg: config.DefaultResource("upjet_resource", nil, nil, nil), s: Setup{ Requirement: ProviderRequirement{ Source: "my-company/namespace/provider-test", @@ -449,7 +449,7 @@ func TestWriteMainTF(t *testing.T) { "obs": "obsval", }}, }, - cfg: config.DefaultResource("upjet_resource", nil, nil), + cfg: config.DefaultResource("upjet_resource", nil, nil, nil), s: Setup{ Requirement: ProviderRequirement{ Source: "hashicorp/provider-test", diff --git a/pkg/terraform/store.go b/pkg/terraform/store.go index 862dab7c..09ffae94 100644 --- a/pkg/terraform/store.go +++ b/pkg/terraform/store.go @@ -19,6 +19,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/logging" "github.com/crossplane/crossplane-runtime/pkg/meta" xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" + fwprovider "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/mitchellh/go-ps" "github.com/pkg/errors" "github.com/spf13/afero" @@ -122,6 +123,8 @@ type Setup struct { Scheduler ProviderScheduler Meta any + + FrameworkProvider fwprovider.Provider } // Map returns the Setup object in map form. The initial reason was so that