From 4436bca36a354c6eae0d718982b469176e22a900 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 9 Oct 2025 15:17:21 -0400 Subject: [PATCH 01/29] Added new fields to image/images datasources and resource --- docs/data-sources/image.md | 10 ++ docs/data-sources/images.md | 10 ++ docs/resources/image.md | 12 ++ go.mod | 18 +- go.sum | 36 ++-- linode/helper/framework_data.go | 86 ++++++++++ linode/image/datasource_test.go | 3 + linode/image/framework_models.go | 175 ++++++++++++++++++-- linode/image/framework_models_unit_test.go | 20 +++ linode/image/framework_schema_datasource.go | 44 +++++ linode/image/framework_schema_resource.go | 47 ++++++ linode/image/resource_test.go | 8 +- linode/images/datasource_test.go | 8 + linode/user/framework_models_unit_test.go | 1 - 14 files changed, 434 insertions(+), 44 deletions(-) diff --git a/docs/data-sources/image.md b/docs/data-sources/image.md index 854c4c106..25899d42f 100644 --- a/docs/data-sources/image.md +++ b/docs/data-sources/image.md @@ -41,6 +41,16 @@ The Linode Image resource exports the following attributes: * `is_public` - True if the Image is public. +* `image_sharing` - Details about image sharing, including who the image is shared with and by. + * `shared_with` - Details about who the image is shared with. + * `sharegroup_count` - The number of sharegroups the private image is present in. + * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. + * `shared_by` - Details about who the image is shared by. + * `sharegroup_id` - The sharegroup_id from the im_ImageShare row. + * `sharegroup_uuid` - The sharegroup_uuid from the im_ImageShare row. + * `sharegroup_label` - The label from the associated im_ImageShareGroup row. + * `source_image_id` - The image id of the base image (will only be shown to producers, will be null for consumers). + * `size` - The minimum size this Image needs to deploy. Size is in MB. example: 2500 * `status` - The current status of this image. (`creating`, `pending_upload`, `available`) diff --git a/docs/data-sources/images.md b/docs/data-sources/images.md index 5f2d3f6ea..138df055d 100644 --- a/docs/data-sources/images.md +++ b/docs/data-sources/images.md @@ -79,6 +79,16 @@ Each Linode image will be stored in the `images` attribute and will export the f * `is_public` - True if the Image is public. +* `image_sharing` - Details about image sharing, including who the image is shared with and by. + * `shared_with` - Details about who the image is shared with. + * `sharegroup_count` - The number of sharegroups the private image is present in. + * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. + * `shared_by` - Details about who the image is shared by. + * `sharegroup_id` - The sharegroup_id from the im_ImageShare row. + * `sharegroup_uuid` - The sharegroup_uuid from the im_ImageShare row. + * `sharegroup_label` - The label from the associated im_ImageShareGroup row. + * `source_image_id` - The image id of the base image (will only be shown to producers, will be null for consumers). + * `size` - The minimum size this Image needs to deploy. Size is in MB. example: 2500 * `status` - The current status of this image. (`creating`, `pending_upload`, `available`) diff --git a/docs/resources/image.md b/docs/resources/image.md index 57665b092..01d2f2ca0 100644 --- a/docs/resources/image.md +++ b/docs/resources/image.md @@ -121,6 +121,18 @@ This resource exports the following attributes: * `is_public` - True if the Image is public. +* `is_shared` - True if the Image is shared. + +* `image_sharing` - Details about image sharing, including who the image is shared with and by. + * `shared_with` - Details about who the image is shared with. + * `sharegroup_count` - The number of sharegroups the private image is present in. + * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. + * `shared_by` - Details about who the image is shared by. + * `sharegroup_id` - The sharegroup_id from the im_ImageShare row. + * `sharegroup_uuid` - The sharegroup_uuid from the im_ImageShare row. + * `sharegroup_label` - The label from the associated im_ImageShareGroup row. + * `source_image_id` - The image id of the base image (will only be shown to producers, will be null for consumers). + * `size` - The minimum size this Image needs to deploy. Size is in MB. * `type` - How the Image was created. 'Manual' Images can be created at any time. 'Automatic' images are created automatically from a deleted Linode. diff --git a/go.mod b/go.mod index 3ad60c52f..488ee1a0e 100644 --- a/go.mod +++ b/go.mod @@ -29,11 +29,13 @@ require ( github.com/linode/linodego v1.57.0 github.com/linode/linodego/k8s v1.25.2 github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.41.0 - golang.org/x/net v0.43.0 + golang.org/x/crypto v0.42.0 + golang.org/x/net v0.44.0 golang.org/x/sync v0.17.0 ) +replace github.com/linode/linodego => github.com/ezilber-akamai/linodego v0.0.0-20251006201737-d25cd1461fd9 + require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/agext/levenshtein v1.2.2 // indirect @@ -101,13 +103,13 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/zclconf/go-cty v1.16.3 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/oauth2 v0.31.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.6.0 // indirect - golang.org/x/tools v0.35.0 // indirect + golang.org/x/tools v0.36.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect google.golang.org/grpc v1.75.1 // indirect diff --git a/go.sum b/go.sum index 84cc67448..7c7d9bd9f 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhF github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/ezilber-akamai/linodego v0.0.0-20251006201737-d25cd1461fd9 h1:E1JfN9gJ9UROVsEEGKiN8ZmMSi8QbuQLoZYJztw8Cj8= +github.com/ezilber-akamai/linodego v0.0.0-20251006201737-d25cd1461fd9/go.mod h1:1+Bt0oTz5rBnDOJbGhccxn7LYVytXTIIfAy7QYmijDs= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= @@ -195,8 +197,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/linode/linodego v1.57.0 h1:B5cl2gRNtaY1TIQ7B4uAhDa8NjtiWdEnWUO8nkHaU0A= -github.com/linode/linodego v1.57.0/go.mod h1:7zol1dqpLRdAT9s04mIaUhA+rXaRERFnKBERcQZiEeg= github.com/linode/linodego/k8s v1.25.2 h1:PY6S0sAD3xANVvM9WY38bz9GqMTjIbytC8IJJ9Cv23o= github.com/linode/linodego/k8s v1.25.2/go.mod h1:DC1XCSRZRGsmaa/ggpDPSDUmOM6aK1bhSIP6+f9Cwhc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -288,23 +288,23 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -326,18 +326,18 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -345,8 +345,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/linode/helper/framework_data.go b/linode/helper/framework_data.go index 0dfd5bfdc..db81114fc 100644 --- a/linode/helper/framework_data.go +++ b/linode/helper/framework_data.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) func KeepOrUpdateString(original types.String, updated string, preserveKnown bool) types.String { @@ -114,3 +115,88 @@ func KeepOrUpdateValue[T attr.Value](original T, updated T, preserveKnown bool) } return updated } + +// KeepOrUpdateSingleNestedAttribute is a convenience wrapper to keep or update a single nested attribute. +// Should only use for the single nested object at root level. For multi-layer nested object, use +// KeepOrUpdateSingleNestedAttributeWithTypes instead. +func KeepOrUpdateSingleNestedAttribute[T any]( + ctx context.Context, + original types.Object, + preserveKnown bool, + diags *diag.Diagnostics, + flatten func(*T, *bool, bool, *diag.Diagnostics), +) *types.Object { + return KeepOrUpdateSingleNestedAttributeWithTypes( + ctx, original, original.AttributeTypes(ctx), preserveKnown, diags, flatten, + ) +} + +// FlattenNestedObjectFunc flattens linodego structs into their corresponding Terraform framework model structs. +// +// Set `isNull` to true if the nested object should be nullified. +// +// For any collection attribute (set, list, map) with a null value, override it with a null value with the +// corresponding element type (e.g., types.SetNull(types.StringType)). This ensures the framework can determine +// the element type when setting the attribute in the state. This is necessary because when the original nested +// object is null or unknown, the KeepOrUpdateSingleNestedAttributeWithTypes function cannot provide element +// type information for the attributes within. +type FlattenNestedObjectFunc[T any] func(model *T, isNull *bool, preserveKnown bool, diags *diag.Diagnostics) + +// This function is necessary when explicit attributes are needed for flatten the `original` +// nested object. +// +// In some cases `original` won't contain the type of its attributes. For example, a +// double nested object (nested object in another nested object) in a model; when the +// parent nested object is null or unknown, `object.As` won't put the attributes into +// the child nested object. Passing explicit attributeTypes will then be necessary. +// +// Checkout the corresponding unit tests for more details. +func KeepOrUpdateSingleNestedAttributeWithTypes[T any]( + ctx context.Context, + original types.Object, + attributeTypes map[string]attr.Type, + preserveKnown bool, + diags *diag.Diagnostics, + flatten FlattenNestedObjectFunc[T], +) *types.Object { + if preserveKnown && original.IsNull() { + return &original + } + + var attrModel T + + if !original.IsUnknown() && !original.IsNull() { + diags.Append( + original.As(ctx, &attrModel, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: false, + UnhandledUnknownAsEmpty: false, + })..., + ) + if diags.HasError() { + return nil + } + } + + preserveKnown = preserveKnown && !original.IsUnknown() + isNull := false + + flatten(&attrModel, &isNull, preserveKnown, diags) + + var updated types.Object + + // Only setting it to null when not preserving known. + // When known values are preserved, it's the flatten function's + // responsibility to handle the values of the nested attributes + if isNull && !preserveKnown { + updated = types.ObjectNull(attributeTypes) + } else { + var newDiags diag.Diagnostics + updated, newDiags = types.ObjectValueFrom(ctx, attributeTypes, attrModel) + diags.Append(newDiags...) + if diags.HasError() { + return nil + } + } + + return &updated +} diff --git a/linode/image/datasource_test.go b/linode/image/datasource_test.go index 8b789a6e4..2f4780191 100644 --- a/linode/image/datasource_test.go +++ b/linode/image/datasource_test.go @@ -29,6 +29,9 @@ func TestAccDataSourceImage_basic(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "label", "Debian 8"), resource.TestCheckResourceAttr(resourceName, "description", ""), resource.TestCheckResourceAttr(resourceName, "is_public", "true"), + resource.TestCheckResourceAttr(resourceName, "is_shared", "false"), + resource.TestCheckNoResourceAttr(resourceName, "image_sharing.shared_with"), + resource.TestCheckNoResourceAttr(resourceName, "image_sharing.shared_by"), resource.TestCheckResourceAttr(resourceName, "type", "manual"), resource.TestCheckResourceAttr(resourceName, "size", "1300"), resource.TestCheckResourceAttr(resourceName, "vendor", "Debian"), diff --git a/linode/image/framework_models.go b/linode/image/framework_models.go index ffc87a048..50c4282df 100644 --- a/linode/image/framework_models.go +++ b/linode/image/framework_models.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/linode/linodego" @@ -27,6 +28,8 @@ type ResourceModel struct { CreatedBy types.String `tfsdk:"created_by"` Deprecated types.Bool `tfsdk:"deprecated"` IsPublic types.Bool `tfsdk:"is_public"` + IsShared types.Bool `tfsdk:"is_shared"` + ImageSharing types.Object `tfsdk:"image_sharing"` Size types.Int64 `tfsdk:"size"` Status types.String `tfsdk:"status"` Type types.String `tfsdk:"type"` @@ -40,6 +43,46 @@ type ResourceModel struct { WaitForReplications types.Bool `tfsdk:"wait_for_replications"` } +type ImageSharingModel struct { + SharedWith types.Object `tfsdk:"shared_with"` + SharedBy types.Object `tfsdk:"shared_by"` +} + +var imageSharingObjectType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "shared_with": imageSharingSharedWithObjectType, + "shared_by": imageSharingSharedByObjectType, + }, +} + +type ImageSharingSharedWithAttributesModel struct { + ShareGroupCount types.Int64 `tfsdk:"sharegroup_count"` + ShareGroupListURL types.String `tfsdk:"sharegroup_list_url"` +} + +var imageSharingSharedWithObjectType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "sharegroup_count": types.Int64Type, + "sharegroup_list_url": types.StringType, + }, +} + +type ImageSharingSharedByAttributesModel struct { + ShareGroupID types.Int64 `tfsdk:"sharegroup_id"` + ShareGroupUUID types.String `tfsdk:"sharegroup_uuid"` + ShareGroupLabel types.String `tfsdk:"sharegroup_label"` + SourceImageID types.String `tfsdk:"source_image_id"` +} + +var imageSharingSharedByObjectType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "sharegroup_id": types.Int64Type, + "sharegroup_uuid": types.StringType, + "sharegroup_label": types.StringType, + "source_image_id": types.StringType, + }, +} + func (data *ResourceModel) FlattenImage( ctx context.Context, image *linodego.Image, @@ -68,6 +111,23 @@ func (data *ResourceModel) FlattenImage( data.CreatedBy = helper.KeepOrUpdateString(data.CreatedBy, image.CreatedBy, preserveKnown) data.Deprecated = helper.KeepOrUpdateBool(data.Deprecated, image.Deprecated, preserveKnown) data.IsPublic = helper.KeepOrUpdateBool(data.IsPublic, image.IsPublic, preserveKnown) + data.IsShared = helper.KeepOrUpdateBool(data.IsShared, image.IsShared, preserveKnown) + + imageSharing := helper.KeepOrUpdateSingleNestedAttributeWithTypes( + ctx, + data.ImageSharing, + imageSharingObjectType.AttrTypes, + preserveKnown, + diags, + func(model *ImageSharingModel, isNull *bool, preserveKnown bool, diags *diag.Diagnostics) { + model.FlattenImageSharing(ctx, image.ImageSharing, preserveKnown, diags) + }, + ) + + if imageSharing != nil { + data.ImageSharing = *imageSharing + } + data.Size = helper.KeepOrUpdateInt64(data.Size, int64(image.Size), preserveKnown) data.Status = helper.KeepOrUpdateString(data.Status, string(image.Status), preserveKnown) data.Type = helper.KeepOrUpdateString(data.Type, image.Type, preserveKnown) @@ -94,6 +154,47 @@ func (data *ResourceModel) FlattenImage( data.Replications = helper.KeepOrUpdateValue(data.Replications, *replications, preserveKnown) } +func (m *ImageSharingModel) FlattenImageSharing(ctx context.Context, imageSharing linodego.ImageSharing, preserveKnown bool, diags *diag.Diagnostics) { + if m.SharedWith.IsNull() { + m.SharedWith = types.ObjectNull(imageSharingSharedWithObjectType.AttrTypes) + } + if m.SharedBy.IsNull() { + m.SharedBy = types.ObjectNull(imageSharingSharedByObjectType.AttrTypes) + } + + if imageSharing.SharedWith != nil { + var sharedWithModel ImageSharingSharedWithAttributesModel + sharedWithModel.FlattenImageSharingSharedWith(*imageSharing.SharedWith, preserveKnown) + objVal, d := types.ObjectValueFrom(ctx, imageSharingSharedWithObjectType.AttrTypes, sharedWithModel) + diags.Append(d...) + m.SharedWith = objVal + } else if !preserveKnown { + m.SharedWith = types.ObjectNull(imageSharingSharedWithObjectType.AttrTypes) + } + + if imageSharing.SharedBy != nil { + var sharedByModel ImageSharingSharedByAttributesModel + sharedByModel.FlattenImageSharingSharedBy(*imageSharing.SharedBy, preserveKnown) + objVal, d := types.ObjectValueFrom(ctx, imageSharingSharedByObjectType.AttrTypes, sharedByModel) + diags.Append(d...) + m.SharedBy = objVal + } else if !preserveKnown { + m.SharedBy = types.ObjectNull(imageSharingSharedByObjectType.AttrTypes) + } +} + +func (m *ImageSharingSharedWithAttributesModel) FlattenImageSharingSharedWith(sharedWith linodego.ImageSharingSharedWith, preserveKnown bool) { + m.ShareGroupCount = helper.KeepOrUpdateInt64(m.ShareGroupCount, int64(sharedWith.ShareGroupCount), preserveKnown) + m.ShareGroupListURL = helper.KeepOrUpdateString(m.ShareGroupListURL, sharedWith.ShareGroupListURL, preserveKnown) +} + +func (m *ImageSharingSharedByAttributesModel) FlattenImageSharingSharedBy(sharedBy linodego.ImageSharingSharedBy, preserveKnown bool) { + m.ShareGroupID = helper.KeepOrUpdateInt64(m.ShareGroupID, int64(sharedBy.ShareGroupID), preserveKnown) + m.ShareGroupUUID = helper.KeepOrUpdateString(m.ShareGroupUUID, sharedBy.ShareGroupUUID, preserveKnown) + m.ShareGroupLabel = helper.KeepOrUpdateString(m.ShareGroupLabel, sharedBy.ShareGroupLabel, preserveKnown) + m.SourceImageID = helper.KeepOrUpdateStringPointer(m.SourceImageID, sharedBy.SourceImageID, preserveKnown) +} + func (data *ResourceModel) CopyFrom(other ResourceModel, preserveKnown bool) { data.ID = helper.KeepOrUpdateValue(data.ID, other.ID, preserveKnown) data.Label = helper.KeepOrUpdateValue(data.Label, other.Label, preserveKnown) @@ -109,6 +210,8 @@ func (data *ResourceModel) CopyFrom(other ResourceModel, preserveKnown bool) { data.CreatedBy = helper.KeepOrUpdateValue(data.CreatedBy, other.CreatedBy, preserveKnown) data.Deprecated = helper.KeepOrUpdateValue(data.Deprecated, other.Deprecated, preserveKnown) data.IsPublic = helper.KeepOrUpdateValue(data.IsPublic, other.IsPublic, preserveKnown) + data.IsShared = helper.KeepOrUpdateValue(data.IsShared, other.IsShared, preserveKnown) + data.ImageSharing = helper.KeepOrUpdateValue(data.ImageSharing, other.ImageSharing, preserveKnown) data.Size = helper.KeepOrUpdateValue(data.Size, other.Size, preserveKnown) data.Status = helper.KeepOrUpdateValue(data.Status, other.Status, preserveKnown) data.Type = helper.KeepOrUpdateValue(data.Type, other.Type, preserveKnown) @@ -125,22 +228,24 @@ func (data *ResourceModel) CopyFrom(other ResourceModel, preserveKnown bool) { // ImageModel describes the Terraform resource data model to match the // resource schema. type ImageModel struct { - ID types.String `tfsdk:"id"` - Label types.String `tfsdk:"label"` - Description types.String `tfsdk:"description"` - Capabilities []types.String `tfsdk:"capabilities"` - Created types.String `tfsdk:"created"` - CreatedBy types.String `tfsdk:"created_by"` - Deprecated types.Bool `tfsdk:"deprecated"` - IsPublic types.Bool `tfsdk:"is_public"` - Size types.Int64 `tfsdk:"size"` - Status types.String `tfsdk:"status"` - Type types.String `tfsdk:"type"` - Expiry types.String `tfsdk:"expiry"` - Vendor types.String `tfsdk:"vendor"` - Tags types.List `tfsdk:"tags"` - TotalSize types.Int64 `tfsdk:"total_size"` - Replications []ReplicationModel `tfsdk:"replications"` + ID types.String `tfsdk:"id"` + Label types.String `tfsdk:"label"` + Description types.String `tfsdk:"description"` + Capabilities []types.String `tfsdk:"capabilities"` + Created types.String `tfsdk:"created"` + CreatedBy types.String `tfsdk:"created_by"` + Deprecated types.Bool `tfsdk:"deprecated"` + IsPublic types.Bool `tfsdk:"is_public"` + IsShared types.Bool `tfsdk:"is_shared"` + ImageSharing *ImageSharingDataSourceModel `tfsdk:"image_sharing"` + Size types.Int64 `tfsdk:"size"` + Status types.String `tfsdk:"status"` + Type types.String `tfsdk:"type"` + Expiry types.String `tfsdk:"expiry"` + Vendor types.String `tfsdk:"vendor"` + Tags types.List `tfsdk:"tags"` + TotalSize types.Int64 `tfsdk:"total_size"` + Replications []ReplicationModel `tfsdk:"replications"` } // ReplicationModel describes an image replication. @@ -149,6 +254,11 @@ type ReplicationModel struct { Status types.String `tfsdk:"status"` } +type ImageSharingDataSourceModel struct { + SharedWith *ImageSharingSharedWithAttributesModel `tfsdk:"shared_with"` + SharedBy *ImageSharingSharedByAttributesModel `tfsdk:"shared_by"` +} + func (data *ImageModel) ParseImage( ctx context.Context, image *linodego.Image, @@ -171,6 +281,7 @@ func (data *ImageModel) ParseImage( data.CreatedBy = types.StringValue(image.CreatedBy) data.Deprecated = types.BoolValue(image.Deprecated) data.IsPublic = types.BoolValue(image.IsPublic) + data.IsShared = types.BoolValue(image.IsShared) data.Size = types.Int64Value(int64(image.Size)) data.Status = types.StringValue(string(image.Status)) data.Type = types.StringValue(image.Type) @@ -184,10 +295,42 @@ func (data *ImageModel) ParseImage( data.Tags = tags data.Replications = parseReplicationModels(image.Regions) + data.ImageSharing = parseImageSharingDataSourceModel(&image.ImageSharing) return nil } +func parseImageSharingDataSourceModel( + imageSharing *linodego.ImageSharing, +) *ImageSharingDataSourceModel { + if imageSharing == nil { + return nil + } + + var sharedWith *ImageSharingSharedWithAttributesModel + if sw := imageSharing.SharedWith; sw != nil { + sharedWith = &ImageSharingSharedWithAttributesModel{ + ShareGroupCount: types.Int64Value(int64(sw.ShareGroupCount)), + ShareGroupListURL: types.StringValue(sw.ShareGroupListURL), + } + } + + var sharedBy *ImageSharingSharedByAttributesModel + if sb := imageSharing.SharedBy; sb != nil { + sharedBy = &ImageSharingSharedByAttributesModel{ + ShareGroupID: types.Int64Value(int64(sb.ShareGroupID)), + ShareGroupUUID: types.StringValue(sb.ShareGroupUUID), + ShareGroupLabel: types.StringValue(sb.ShareGroupLabel), + SourceImageID: types.StringPointerValue(sb.SourceImageID), + } + } + + return &ImageSharingDataSourceModel{ + SharedWith: sharedWith, + SharedBy: sharedBy, + } +} + func parseReplicationModels( regions []linodego.ImageRegion, ) []ReplicationModel { diff --git a/linode/image/framework_models_unit_test.go b/linode/image/framework_models_unit_test.go index 12d51136f..9cda4161e 100644 --- a/linode/image/framework_models_unit_test.go +++ b/linode/image/framework_models_unit_test.go @@ -26,6 +26,7 @@ func TestParseImage(t *testing.T) { Status: "available", Size: 2500, IsPublic: true, + IsShared: false, Deprecated: false, Created: createdTime, Expiry: nil, @@ -41,6 +42,18 @@ func TestParseImage(t *testing.T) { Status: linodego.ImageRegionStatus("pending replication"), }, }, + ImageSharing: linodego.ImageSharing{ + SharedWith: linodego.Pointer(linodego.ImageSharingSharedWith{ + ShareGroupCount: 1, + ShareGroupListURL: "/images/private/1234/sharegroups", + }), + SharedBy: linodego.Pointer(linodego.ImageSharingSharedBy{ + ShareGroupID: 1, + ShareGroupUUID: "0ee8e1c1-b19b-4052-9487-e3b13faac111", + ShareGroupLabel: "my-label", + SourceImageID: linodego.Pointer("private/1234"), + }), + }, } var imageModel ImageModel @@ -56,6 +69,7 @@ func TestParseImage(t *testing.T) { assert.Equal(t, types.StringValue("available"), imageModel.Status) assert.Equal(t, types.Int64Value(2500), imageModel.Size) assert.Equal(t, types.BoolValue(true), imageModel.IsPublic) + assert.Equal(t, types.BoolValue(false), imageModel.IsShared) assert.Equal(t, types.BoolValue(false), imageModel.Deprecated) assert.Equal(t, imageModel.Created, types.StringValue(createdTimeFormatted)) assert.Empty(t, imageModel.Expiry) @@ -64,5 +78,11 @@ func TestParseImage(t *testing.T) { assert.Equal(t, types.StringValue("available"), imageModel.Replications[0].Status) assert.Equal(t, types.StringValue("us-west"), imageModel.Replications[1].Region) assert.Equal(t, types.StringValue("pending replication"), imageModel.Replications[1].Status) + assert.Equal(t, types.Int64Value(1), imageModel.ImageSharing.SharedWith.ShareGroupCount) + assert.Equal(t, types.StringValue("/images/private/1234/sharegroups"), imageModel.ImageSharing.SharedWith.ShareGroupListURL) + assert.Equal(t, types.Int64Value(1), imageModel.ImageSharing.SharedBy.ShareGroupID) + assert.Equal(t, types.StringValue("0ee8e1c1-b19b-4052-9487-e3b13faac111"), imageModel.ImageSharing.SharedBy.ShareGroupUUID) + assert.Equal(t, types.StringValue("my-label"), imageModel.ImageSharing.SharedBy.ShareGroupLabel) + assert.Equal(t, types.StringValue("private/1234"), imageModel.ImageSharing.SharedBy.SourceImageID) assert.Contains(t, imageModel.Tags.String(), "test") } diff --git a/linode/image/framework_schema_datasource.go b/linode/image/framework_schema_datasource.go index 19f5997a0..3e47df6d6 100644 --- a/linode/image/framework_schema_datasource.go +++ b/linode/image/framework_schema_datasource.go @@ -39,6 +39,50 @@ var ImageAttributes = map[string]schema.Attribute{ Description: "True if the Image is public.", Computed: true, }, + "is_shared": schema.BoolAttribute{ + Description: "True if the Image is shared.", + Computed: true, + }, + "image_sharing": schema.SingleNestedAttribute{ + Description: "Details about image sharing, including who the image is shared with and by.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "shared_with": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "sharegroup_count": schema.Int64Attribute{ + Description: "The number of sharegroups the private image is present in.", + Computed: true, + }, + "sharegroup_list_url": schema.StringAttribute{ + Description: "The GET api url to view the sharegroups in which the image is shared.", + Computed: true, + }, + }, + }, + "shared_by": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "sharegroup_id": schema.Int64Attribute{ + Description: "The sharegroup_id from the im_ImageShare row.", + Computed: true, + }, + "sharegroup_uuid": schema.StringAttribute{ + Description: "The sharegroup_uuid from the im_ImageShare row.", + Computed: true, + }, + "sharegroup_label": schema.StringAttribute{ + Description: "The label from the associated im_ImageShareGroup row.", + Computed: true, + }, + "source_image_id": schema.StringAttribute{ + Description: "The image id of the base image (will only be shown to producers, will be None for consumers).", + Computed: true, + }, + }, + }, + }, + }, "size": schema.Int64Attribute{ Description: "The minimum size this Image needs to deploy. Size is in MB.", Computed: true, diff --git a/linode/image/framework_schema_resource.go b/linode/image/framework_schema_resource.go index 301bd763b..f76af5db4 100644 --- a/linode/image/framework_schema_resource.go +++ b/linode/image/framework_schema_resource.go @@ -149,6 +149,53 @@ var frameworkResourceSchema = schema.Schema{ boolplanmodifier.UseStateForUnknown(), }, }, + "is_shared": schema.BoolAttribute{ + Description: "True if the Image is shared.", + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "image_sharing": schema.SingleNestedAttribute{ + Description: "Details about image sharing, including who the image is shared with and by.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "shared_with": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "sharegroup_count": schema.Int64Attribute{ + Description: "The number of sharegroups the private image is present in.", + Computed: true, + }, + "sharegroup_list_url": schema.StringAttribute{ + Description: "The GET api url to view the sharegroups in which the image is shared.", + Computed: true, + }, + }, + }, + "shared_by": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "sharegroup_id": schema.Int64Attribute{ + Description: "The sharegroup_id from the im_ImageShare row.", + Computed: true, + }, + "sharegroup_uuid": schema.StringAttribute{ + Description: "The sharegroup_uuid from the im_ImageShare row.", + Computed: true, + }, + "sharegroup_label": schema.StringAttribute{ + Description: "The label from the associated im_ImageShareGroup row.", + Computed: true, + }, + "source_image_id": schema.StringAttribute{ + Description: "The image id of the base image (will only be shown to producers, will be null for consumers).", + Computed: true, + }, + }, + }, + }, + }, "size": schema.Int64Attribute{ Description: "The minimum size this Image needs to deploy. Size is in MB.", Computed: true, diff --git a/linode/image/resource_test.go b/linode/image/resource_test.go index baca78737..2af8b2d47 100644 --- a/linode/image/resource_test.go +++ b/linode/image/resource_test.go @@ -70,7 +70,7 @@ func init() { return !ok || !isDisallowed }) - testRegion = testRegions[1] + testRegion = testRegions[0] } func sweep(prefix string) error { @@ -121,6 +121,12 @@ func TestAccImage_basic(t *testing.T) { resource.TestCheckResourceAttrSet(resName, "size"), resource.TestCheckResourceAttr(resName, "type", "manual"), resource.TestCheckResourceAttr(resName, "is_public", "false"), + resource.TestCheckResourceAttr(resName, "is_shared", "false"), + resource.TestCheckResourceAttr(resName, "image_sharing.shared_with.sharegroup_count", "0"), + resource.TestCheckResourceAttrSet( + resName, + "image_sharing.shared_with.sharegroup_list_url", + ), resource.TestCheckResourceAttr(resName, "capabilities.0", "cloud-init"), resource.TestCheckResourceAttrSet(resName, "deprecated"), resource.TestCheckResourceAttr(resName, "tags.#", "1"), diff --git a/linode/images/datasource_test.go b/linode/images/datasource_test.go index 157bb4c24..b070e41f8 100644 --- a/linode/images/datasource_test.go +++ b/linode/images/datasource_test.go @@ -41,6 +41,10 @@ func TestAccDataSourceImages_basic_smoke(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "images.0.label", imageName), resource.TestCheckResourceAttr(resourceName, "images.0.description", "descriptive text"), resource.TestCheckResourceAttr(resourceName, "images.0.is_public", "false"), + resource.TestCheckResourceAttr(resourceName, "images.0.is_shared", "false"), + resource.TestCheckResourceAttr(resourceName, "images.0.image_sharing.shared_with.sharegroup_count", "0"), + resource.TestCheckResourceAttrSet(resourceName, "images.0.image_sharing.shared_with.sharegroup_list_url"), + resource.TestCheckNoResourceAttr(resourceName, "image_sharing.shared_by"), resource.TestCheckResourceAttr(resourceName, "images.0.type", "manual"), acceptance.CheckListContains(resourceName, "images.0.tags", "test"), resource.TestCheckResourceAttrSet(resourceName, "images.0.created"), @@ -53,6 +57,10 @@ func TestAccDataSourceImages_basic_smoke(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "images.1.label", imageName), resource.TestCheckResourceAttr(resourceName, "images.1.description", "descriptive text"), resource.TestCheckResourceAttr(resourceName, "images.1.is_public", "false"), + resource.TestCheckResourceAttr(resourceName, "images.0.is_shared", "false"), + resource.TestCheckResourceAttr(resourceName, "images.0.image_sharing.shared_with.sharegroup_count", "0"), + resource.TestCheckResourceAttrSet(resourceName, "images.0.image_sharing.shared_with.sharegroup_list_url"), + resource.TestCheckNoResourceAttr(resourceName, "image_sharing.shared_by"), resource.TestCheckResourceAttr(resourceName, "images.1.type", "manual"), acceptance.CheckListContains(resourceName, "images.1.tags", "test"), resource.TestCheckResourceAttrSet(resourceName, "images.1.created"), diff --git a/linode/user/framework_models_unit_test.go b/linode/user/framework_models_unit_test.go index e95c62758..ba5e88e6a 100644 --- a/linode/user/framework_models_unit_test.go +++ b/linode/user/framework_models_unit_test.go @@ -75,7 +75,6 @@ func TestParseUserGrants(t *testing.T) { AddLinodes: true, AddLongview: true, AddNodeBalancers: true, - AddPlacementGroups: true, AddStackScripts: true, AddVolumes: true, AddVPCs: true, From ac250a52d0a6eee3d12179e9e43813530ea76e7f Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 15 Oct 2025 18:10:46 -0400 Subject: [PATCH 02/29] Project: Linode Interfaces (#1862) * Replace linodego with the feature branch version of it * Add `interfaces_for_new_linodes` attribute in account setting resource and data source (#1864) * Sync linodego feature branch * Support `config_id` in the linode interfaces in subnet resources and data sources (#1896) * Support `config_id` in the interfaces of linodes in subnet resources and data sources * Add helper func for ptr conversion * Implement firewall template and templates data sources (#1873) * sync with linodego feature branch * Linode Interfaces: Implement changes under linode_instance resource and data source (#1890) * Linode Interfaces: Add non-interface /linode/instances fields * WIP * Drop validation and add partial docs * Fix up docs * Sort imports * Update replacement * oops * remove TODO * Minor docs change * Revert replace * ADd TODO * Remove trailing space * Add interface_id in networking IP data sources (#1898) * Add interface_id in various networking IP data sources * Fix test * Update docs * Update VPC and account setting docs (#1903) * Update VPC and account setting docs * Fix descriptions in schema * sync with linodego feature branch * Implement firewall settings data source (#1905) * Implement firewall settings data source * gofumpt * Add test * Fix * Upgrade some tests to be with protocol v6 factory * Add support for interfaces in firewall resource and data source (#1899) * Add support for interfaces in firewall resource and data source * Add unit test * Add TODO for acceptance tests * Update docs * Add interfaces support in firewalls data source (#1902) * Add interfaces support in firewalls data source * Update and migrate tests * Update doc * Migrate to firewall settings data source to be with nested object (#1947) * golangci-lint run --fix && golangci-lint fmt * Sync linodego version * Set config_id to null attribute when it's Go zero value (#1953) Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Implement firewall settings resource (#1963) * Implementation and tests for linode_firewall_settings resource * Add doc * Remove ID referencing * Fix doc * Add tag for integration test * Fix * golangci-lint fmt * Cleanup * Fix nil pointer panic * Sync linodego * Sync linodego feature branch * go mod tidy * golangci-lint fmt * Repin linodego to released version * Fix * Add nested object update helper (#2002) * Implement nested object update helper * Add isNull return (pass by ptr) * FIx * Minor change * Implement linode interface resource (#2087) * Implement linode interface resource * Cleanup boolTrue and boolFalse * Cleanup debugging stuff * cleanup * golangci-lint fmt * Adjust importing IDs order * Remove redundant * Improved doc * improved test * Fix lint * Copilot fixed doc * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode/firewall/framework_models.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode/firewalltemplates/tmpl/data_filter.gotf Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/data-sources/vpc_subnets.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/data-sources/vpc_subnet.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode/firewallsettings/framework_resource.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/data-sources/account_settings.md | 2 + docs/data-sources/firewall.md | 2 + docs/data-sources/firewall_template.md | 39 + docs/data-sources/firewall_templates.md | 66 ++ docs/data-sources/firewalls.md | 6 +- docs/data-sources/instance_networking.md | 6 + docs/data-sources/instances.md | 2 + docs/data-sources/networking_ip.md | 2 + docs/data-sources/networking_ips.md | 2 + docs/data-sources/vpc_subnet.md | 9 +- docs/data-sources/vpc_subnets.md | 9 +- docs/resources/account_settings.md | 2 + docs/resources/firewall.md | 2 + docs/resources/instance.md | 6 + docs/resources/interface.md | 297 +++++++ docs/resources/linode_firewall_settings.md | 35 + docs/resources/vpc_subnet.md | 7 +- linode/accountsettings/datasource_test.go | 25 +- .../framework_datasource_schema.go | 4 + linode/accountsettings/framework_model.go | 23 +- .../framework_model_unit_test.go | 17 +- linode/accountsettings/framework_resource.go | 55 +- .../framework_resource_schema.go | 17 +- linode/accountsettings/resource_test.go | 65 +- linode/accountsettings/tmpl/template.go | 20 +- linode/accountsettings/tmpl/updates.gotf | 1 + linode/firewall/firewall_helpers_unit_test.go | 7 +- linode/firewall/framework_datasource.go | 11 +- linode/firewall/framework_datasource_test.go | 1 + linode/firewall/framework_models.go | 56 +- linode/firewall/framework_models_unit_test.go | 13 +- linode/firewall/framework_resource.go | 16 +- linode/firewall/framework_resource_test.go | 2 + .../firewall/framework_schema_datasource.go | 5 + linode/firewall/framework_schema_resource.go | 9 + linode/firewalls/datasource_test.go | 88 -- linode/firewalls/framework_datasource_test.go | 232 +++++ linode/firewalls/framework_models.go | 4 + linode/firewalls/framework_schema.go | 7 +- .../firewallsettings/framework_datasource.go | 51 ++ .../framework_datasource_schema.go | 20 + .../framework_datasource_test.go | 41 + .../framework_model_unit_test.go | 139 +++ linode/firewallsettings/framework_models.go | 113 +++ linode/firewallsettings/framework_resource.go | 158 ++++ .../framework_resource_schema.go | 50 ++ .../framework_resource_test.go | 108 +++ linode/firewallsettings/tmpl/basic.gotf | 12 + linode/firewallsettings/tmpl/data.gotf | 6 + linode/firewallsettings/tmpl/template.go | 26 + .../firewalltemplate/framework_datasource.go | 63 ++ .../framework_datasource_test.go | 41 + linode/firewalltemplate/framework_models.go | 59 ++ linode/firewalltemplate/framework_schema.go | 39 + linode/firewalltemplate/tmpl/data_basic.gotf | 7 + linode/firewalltemplate/tmpl/template.go | 18 + .../firewalltemplates/framework_datasource.go | 85 ++ .../framework_datasource_test.go | 68 ++ linode/firewalltemplates/framework_models.go | 28 + linode/firewalltemplates/framework_schema.go | 57 ++ linode/firewalltemplates/tmpl/data_basic.gotf | 6 + .../firewalltemplates/tmpl/data_filter.gotf | 10 + linode/firewalltemplates/tmpl/template.go | 24 + linode/framework_provider.go | 9 + linode/helper/conversion.go | 41 + linode/helper/framework_data.go | 128 +++ linode/helper/framework_data_test.go | 444 ++++++++++ .../setplanmodifiers/usestateforunknownif.go | 80 +- .../usestateforunknownif_test.go | 18 +- linode/instance/datasource.go | 11 +- linode/instance/datasource_test.go | 61 ++ linode/instance/flatten.go | 2 + linode/instance/resource.go | 9 + linode/instance/resource_test.go | 130 +++ linode/instance/schema_datasource.go | 6 + linode/instance/schema_resource.go | 19 + linode/instance/tmpl/template.go | 41 +- .../data_explicit_interface_generation.gotf | 17 + .../explicit_interface_generation.gotf | 20 + linode/instancenetworking/datasource_test.go | 2 + .../framework_datasource_schema.go | 21 +- linode/instancenetworking/framework_models.go | 6 + .../framework_default_route_model.go | 33 + linode/linodeinterface/framework_models.go | 219 +++++ .../framework_public_models.go | 288 ++++++ linode/linodeinterface/framework_resource.go | 248 ++++++ .../framework_resource_schema.go | 395 +++++++++ .../framework_resource_test.go | 834 ++++++++++++++++++ .../linodeinterface/framework_vlan_models.go | 35 + .../linodeinterface/framework_vpc_models.go | 198 +++++ linode/linodeinterface/tmpl/public_basic.gotf | 25 + .../tmpl/public_default_ip.gotf | 16 + .../tmpl/public_default_route_ipv6.gotf | 36 + .../tmpl/public_empty_ip_objects.gotf | 19 + linode/linodeinterface/tmpl/public_ipv4.gotf | 25 + .../tmpl/public_ipv4_ipv6.gotf | 32 + linode/linodeinterface/tmpl/public_ipv6.gotf | 27 + .../tmpl/public_updated_ipv4_ipv6.gotf | 39 + linode/linodeinterface/tmpl/template.go | 145 +++ linode/linodeinterface/tmpl/vlan_basic.gotf | 19 + linode/linodeinterface/tmpl/vpc_basic.gotf | 30 + .../linodeinterface/tmpl/vpc_default_ip.gotf | 30 + .../tmpl/vpc_default_route_ipv4.gotf | 40 + .../tmpl/vpc_empty_ip_objects.gotf | 32 + .../linodeinterface/tmpl/vpc_with_ipv4.gotf | 38 + linode/nb/framework_models.go | 5 +- linode/networkingip/datasource_test.go | 60 -- .../framework_datasource_model.go | 25 +- .../framework_datasource_schema.go | 4 + .../networkingip/framework_datasource_test.go | 79 ++ .../famework_datasource_schema.go | 4 + .../framework_datasource_models.go | 25 +- ...e_test.go => framework_datasource_test.go} | 36 +- linode/vpcips/framework_models.go | 8 +- linode/vpcsubnet/framework_models.go | 5 +- linode/vpcsubnet/framework_schema_resource.go | 6 +- ...e_test.go => framework_datasource_test.go} | 65 +- 117 files changed, 6245 insertions(+), 346 deletions(-) create mode 100644 docs/data-sources/firewall_template.md create mode 100644 docs/data-sources/firewall_templates.md create mode 100644 docs/resources/interface.md create mode 100644 docs/resources/linode_firewall_settings.md delete mode 100644 linode/firewalls/datasource_test.go create mode 100644 linode/firewalls/framework_datasource_test.go create mode 100644 linode/firewallsettings/framework_datasource.go create mode 100644 linode/firewallsettings/framework_datasource_schema.go create mode 100644 linode/firewallsettings/framework_datasource_test.go create mode 100644 linode/firewallsettings/framework_model_unit_test.go create mode 100644 linode/firewallsettings/framework_models.go create mode 100644 linode/firewallsettings/framework_resource.go create mode 100644 linode/firewallsettings/framework_resource_schema.go create mode 100644 linode/firewallsettings/framework_resource_test.go create mode 100644 linode/firewallsettings/tmpl/basic.gotf create mode 100644 linode/firewallsettings/tmpl/data.gotf create mode 100644 linode/firewallsettings/tmpl/template.go create mode 100644 linode/firewalltemplate/framework_datasource.go create mode 100644 linode/firewalltemplate/framework_datasource_test.go create mode 100644 linode/firewalltemplate/framework_models.go create mode 100644 linode/firewalltemplate/framework_schema.go create mode 100644 linode/firewalltemplate/tmpl/data_basic.gotf create mode 100644 linode/firewalltemplate/tmpl/template.go create mode 100644 linode/firewalltemplates/framework_datasource.go create mode 100644 linode/firewalltemplates/framework_datasource_test.go create mode 100644 linode/firewalltemplates/framework_models.go create mode 100644 linode/firewalltemplates/framework_schema.go create mode 100644 linode/firewalltemplates/tmpl/data_basic.gotf create mode 100644 linode/firewalltemplates/tmpl/data_filter.gotf create mode 100644 linode/firewalltemplates/tmpl/template.go create mode 100644 linode/helper/framework_data_test.go create mode 100644 linode/instance/tmpl/templates/data_explicit_interface_generation.gotf create mode 100644 linode/instance/tmpl/templates/explicit_interface_generation.gotf create mode 100644 linode/linodeinterface/framework_default_route_model.go create mode 100644 linode/linodeinterface/framework_models.go create mode 100644 linode/linodeinterface/framework_public_models.go create mode 100644 linode/linodeinterface/framework_resource.go create mode 100644 linode/linodeinterface/framework_resource_schema.go create mode 100644 linode/linodeinterface/framework_resource_test.go create mode 100644 linode/linodeinterface/framework_vlan_models.go create mode 100644 linode/linodeinterface/framework_vpc_models.go create mode 100644 linode/linodeinterface/tmpl/public_basic.gotf create mode 100644 linode/linodeinterface/tmpl/public_default_ip.gotf create mode 100644 linode/linodeinterface/tmpl/public_default_route_ipv6.gotf create mode 100644 linode/linodeinterface/tmpl/public_empty_ip_objects.gotf create mode 100644 linode/linodeinterface/tmpl/public_ipv4.gotf create mode 100644 linode/linodeinterface/tmpl/public_ipv4_ipv6.gotf create mode 100644 linode/linodeinterface/tmpl/public_ipv6.gotf create mode 100644 linode/linodeinterface/tmpl/public_updated_ipv4_ipv6.gotf create mode 100644 linode/linodeinterface/tmpl/template.go create mode 100644 linode/linodeinterface/tmpl/vlan_basic.gotf create mode 100644 linode/linodeinterface/tmpl/vpc_basic.gotf create mode 100644 linode/linodeinterface/tmpl/vpc_default_ip.gotf create mode 100644 linode/linodeinterface/tmpl/vpc_default_route_ipv4.gotf create mode 100644 linode/linodeinterface/tmpl/vpc_empty_ip_objects.gotf create mode 100644 linode/linodeinterface/tmpl/vpc_with_ipv4.gotf delete mode 100644 linode/networkingip/datasource_test.go create mode 100644 linode/networkingip/framework_datasource_test.go rename linode/networkingips/{datasource_test.go => framework_datasource_test.go} (63%) rename linode/vpcsubnets/{datasource_test.go => framework_datasource_test.go} (61%) diff --git a/docs/data-sources/account_settings.md b/docs/data-sources/account_settings.md index 1002906a1..a9c14d129 100644 --- a/docs/data-sources/account_settings.md +++ b/docs/data-sources/account_settings.md @@ -23,6 +23,8 @@ data "linode_account_settings" "example" {} * `longview_subscription` - The Longview Pro tier you are currently subscribed to. +* `interfaces_for_new_linodes` - Type of interfaces for new Linode instances. + * `managed` - Enables monitoring for connectivity, response, and total request time. * `network_helper` - Enables network helper across all users by default for new Linodes and Linode Configs. diff --git a/docs/data-sources/firewall.md b/docs/data-sources/firewall.md index 983f94490..6b293c522 100644 --- a/docs/data-sources/firewall.md +++ b/docs/data-sources/firewall.md @@ -45,6 +45,8 @@ In addition to all arguments above, the following attributes are exported: * `nodebalancers` - The IDs of NodeBalancers assigned to this Firewall. +* `interfaces` - The IDs of Linode interfaces assigned to this Firewall. + * `status` - The status of the firewall. (`enabled`, `disabled`, `deleted`) * `created` - When this firewall was created. diff --git a/docs/data-sources/firewall_template.md b/docs/data-sources/firewall_template.md new file mode 100644 index 000000000..bd55e5248 --- /dev/null +++ b/docs/data-sources/firewall_template.md @@ -0,0 +1,39 @@ +--- +page_title: "Linode: linode_firewall_template" +description: |- + Provides details about a Linode Firewall Template. +--- + +# Data Source: linode\_firewall\_template + +Provides information about a Linode Firewall Template. + +## Example Usage + +The following example shows how one might use this data source to access information about a specific Firewall Template: + +```hcl +data "linode_firewall_template" "public-template" { + slug = "public" +} + +output "firewall_template_id" { + value = data.linode_firewall_template.public-template.id +} +``` + +## Argument Reference + +The following arguments are supported: + +* `slug` - (Required) The slug of the firewall template. + +## Attributes Reference + +The following attributes are exported: + +* `id` - The computed ID of the data source, which matches the `slug` attribute. +* `inbound` - A list of firewall rules specifying allowed inbound network traffic. +* `inbound_policy` - The default behavior for inbound traffic. This can be overridden by individual firewall rules. +* `outbound` - A list of firewall rules specifying allowed outbound network traffic. +* `outbound_policy` - The default behavior for outbound traffic. This can be overridden by individual firewall rules. diff --git a/docs/data-sources/firewall_templates.md b/docs/data-sources/firewall_templates.md new file mode 100644 index 000000000..db1c11502 --- /dev/null +++ b/docs/data-sources/firewall_templates.md @@ -0,0 +1,66 @@ +--- +page_title: "Linode: linode_firewall_templates" +description: |- + Lists Linode Firewall Templates available on your account. +--- + +# Data Source: linode\_firewall\_templates + +Provides information about all Linode Firewall Templates. + +## Example Usage + +The following example shows how one might use this data source to list all available Firewall Templates: + +```hcl +data "linode_firewall_templates" "all" {} + +output "firewall_template_slugs" { + value = data.linode_firewall_templates.all.firewall_templates +} +``` + +Or with some filters to get a subset of the results. + +```hcl +data "linode_firewall_templates" "filtered" { + filter { + name = "slug" + values = ["public"] + match_by = "exact" + } +} + +output "firewall_template_slugs" { + value = data.linode_firewall_templates.filtered.firewall_templates +} +``` + +## Argument Reference + +The following arguments are supported: + +* [`filter`](#filter) - (Optional) A set of filters used to select Linode Cloud Firewalls that meet certain requirements. + +### Filter + +* `name` - (Required) The name of the field to filter by. See the [Filterable Fields section](#filterable-fields) for a complete list of filterable fields. + +* `values` - (Required) A list of values for the filter to allow. These values should all be in string form. + +* `match_by` - (Optional) The method to match the field by. (`exact`, `regex`, `substring`; default `exact`) + +## Attributes Reference + +The following attributes are exported: + +* `templates` - A list of firewall templates, where each template includes: + * `slug` - The slug of the firewall template. + * `inbound` - A list of firewall rules specifying allowed inbound network traffic. + * `inbound_policy` - The default behavior for inbound traffic. + * `outbound` - A list of firewall rules specifying allowed outbound network traffic. + * `outbound_policy` - The default behavior for outbound traffic. + +## Filterable Fields + +* `slug` diff --git a/docs/data-sources/firewalls.md b/docs/data-sources/firewalls.md index 490b7ee5b..b32a5a0b7 100644 --- a/docs/data-sources/firewalls.md +++ b/docs/data-sources/firewalls.md @@ -61,7 +61,7 @@ The following arguments are supported: ## Attributes Reference -Each Linode image will be stored in the `firewalls` attribute and will export the following attributes: +Each Linode firewall will be stored in the `firewalls` attribute and will export the following attributes: * `id` - The unique ID assigned to this Firewall. @@ -83,6 +83,10 @@ Each Linode image will be stored in the `firewalls` attribute and will export th * `linodes` - The IDs of Linodes this firewall is applied to. +* `nodebalancers` - The IDs of NodeBalancers this firewall is applied to. + +* `interfaces` - The IDs of Linode Interfaces this firewall is applied to. + * `status` - The status of the firewall. * `created` - When this firewall was created. diff --git a/docs/data-sources/instance_networking.md b/docs/data-sources/instance_networking.md index 1e90b4380..17a64ce1d 100644 --- a/docs/data-sources/instance_networking.md +++ b/docs/data-sources/instance_networking.md @@ -85,6 +85,8 @@ A list of public IP Address objects belonging to this Linode. * `gateway` - (Nullable) The default gateway for this address. +* `interface_id` - The Linode interface ID that this IP address is assigned to. + * `linode_id` - The ID of the Linode this address currently belongs to. For IPv4 addresses, this is by default the Linode that this address was assigned to on creation, and these addresses my be moved using the [/networking/ipv4/assign](https://techdocs.akamai.com/linode-api/reference/post-assign-ips) endpoint. For SLAAC and link-local addresses, this value may not be changed. * `prefix` - The number of bits set in the subnet mask. @@ -112,6 +114,8 @@ A list of reserved IP Address objects belonging to this Linode. * `gateway` - (Nullable) The default gateway for this address. +* `interface_id` - The Linode interface ID that this IP address is assigned to. + * `linode_id` - The ID of the Linode this address currently belongs to. For IPv4 addresses, this is by default the Linode that this address was assigned to on creation, and these addresses my be moved using the [/networking/ipv4/assign](https://techdocs.akamai.com/linode-api/reference/post-assign-ips) endpoint. For SLAAC and link-local addresses, this value may not be changed. * `prefix` - The number of bits set in the subnet mask. @@ -139,6 +143,8 @@ A list of shared IP Address objects assigned to this Linode. * `gateway` - (Nullable) The default gateway for this address. +* `interface_id` - The Linode interface ID that this IP address is assigned to. + * `linode_id` - The ID of the Linode this address currently belongs to. For IPv4 addresses, this is by default the Linode that this address was assigned to on creation, and these addresses my be moved using the [/networking/ipv4/assign](https://techdocs.akamai.com/linode-api/reference/post-assign-ips) endpoint. For SLAAC and link-local addresses, this value may not be changed. * `prefix` - The number of bits set in the subnet mask. diff --git a/docs/data-sources/instances.md b/docs/data-sources/instances.md index 98cc17ec4..3a5642938 100644 --- a/docs/data-sources/instances.md +++ b/docs/data-sources/instances.md @@ -109,6 +109,8 @@ Each Linode instance will be stored in the `instances` attribute and will export * `has_user_data` - Whether this Instance was created with user-data. +* `interface_generation` - The interface type for this Instance. (`linode`, `legacy_config`) + * `disk_encryption` - The disk encryption policy for this instance. * **NOTE: Disk encryption may not currently be available to all users.** diff --git a/docs/data-sources/networking_ip.md b/docs/data-sources/networking_ip.md index 033669a0e..fd54ccb5e 100644 --- a/docs/data-sources/networking_ip.md +++ b/docs/data-sources/networking_ip.md @@ -45,6 +45,8 @@ The Linode Network IP Address resource exports the following attributes: * `linode_id` - The ID of the Linode this address currently belongs to. +* `interface_id` - The ID of the interface this address is assigned to. + * `region` - The Region this IP address resides in. See all regions [here](https://api.linode.com/v4/regions). * `reserved` - Whether this IP address is a reserved IP. diff --git a/docs/data-sources/networking_ips.md b/docs/data-sources/networking_ips.md index f2524ac21..c040f2556 100644 --- a/docs/data-sources/networking_ips.md +++ b/docs/data-sources/networking_ips.md @@ -65,6 +65,8 @@ Each IP address will be stored in the `ip_addresses` attribute and will export t * `linode_id` - The ID of the Linode this address currently belongs to. +* `interface_id` - The ID of the interface this address is assigned to. + * `region` - The Region this IP address resides in. See all regions [here](https://api.linode.com/v4/regions). * `reserved` - Whether this IP address is a reserved IP. diff --git a/docs/data-sources/vpc_subnet.md b/docs/data-sources/vpc_subnet.md index ddc45498a..9356608ad 100644 --- a/docs/data-sources/vpc_subnet.md +++ b/docs/data-sources/vpc_subnet.md @@ -40,9 +40,14 @@ In addition to all arguments above, the following attributes are exported: * `ipv4` - The IPv4 range of this subnet in CIDR format. -* [`ipv6`](#ipv6) - A list of IPv6 ranges under this subnet. +* `linodes` - A list of Linodes added to this subnet. + * `id` - ID of the Linode + * `interfaces` - A list of networking interfaces objects. + * `id` - ID of the interface. + * `config_id` - ID of Linode Config that the interface is associated with. `null` for a Linode Interface. + * `active` - Whether the Interface is actively in use. -* `linodes` - A list of Linode IDs that added to this subnet. +* [`ipv6`](#ipv6) - A list of IPv6 ranges under this subnet. * `created` - The date and time when the VPC Subnet was created. diff --git a/docs/data-sources/vpc_subnets.md b/docs/data-sources/vpc_subnets.md index f93a15a15..8fa56579a 100644 --- a/docs/data-sources/vpc_subnets.md +++ b/docs/data-sources/vpc_subnets.md @@ -53,9 +53,14 @@ Each Linode VPC subnet will be stored in the `vpc_subnets` attribute and will ex * `ipv4` - The IPv4 range of this subnet in CIDR format. -* [`ipv6`](#ipv6) - A list of IPv6 ranges under this subnet. +* `linodes` - A list of Linodes added to this subnet. + * `id` - ID of the Linode + * `interfaces` - A list of networking interfaces objects. + * `id` - ID of the interface. + * `config_id` - ID of Linode Config that the interface is associated with. `null` for a Linode Interface. + * `active` - Whether the Interface is actively in use. -* `linodes` - A list of Linode IDs that added to this subnet. +* [`ipv6`](#ipv6) - A list of IPv6 ranges under this subnet. * `created` - The date and time when the VPC Subnet was created. diff --git a/docs/resources/account_settings.md b/docs/resources/account_settings.md index 8872af86f..a077621b4 100644 --- a/docs/resources/account_settings.md +++ b/docs/resources/account_settings.md @@ -30,6 +30,8 @@ The following arguments are supported: * `longview_subscription` - (Optional) The Longview Pro tier you are currently subscribed to. The value must be a [Longview Subscription](https://techdocs.akamai.com/linode-api/reference/get-longview-subscriptions) ID or null for Longview Free. +* `interfaces_for_new_linodes` - (Optional) Type of interfaces for new Linode instances. Available values are `"legacy_config_only"`, `"legacy_config_default_but_linode_allowed"`, `"linode_default_but_legacy_config_allowed"`, and `"linode_only"`. + * `maintenance_policy` - (Optional) The default maintenance policy for this account. Examples are `"linode/migrate"` and `"linode/power_off_on"`. Defaults to `"linode/migrate"`. (**Note: v4beta only.**) ## Additional Results diff --git a/docs/resources/firewall.md b/docs/resources/firewall.md index a91c1c022..4c31d98aa 100644 --- a/docs/resources/firewall.md +++ b/docs/resources/firewall.md @@ -90,6 +90,8 @@ The following arguments are supported: * `nodebalancers` - (Optional) A list of IDs of NodeBalancers this Firewall should govern network traffic for. +* `interfaces` - (Optional) A list of IDs of Linode Interfaces this Firewall should govern network traffic for. + * `tags` - (Optional) A list of tags applied to the Kubernetes cluster. Tags are case-insensitive and are for organizational purposes only. ### inbound and outbound diff --git a/docs/resources/instance.md b/docs/resources/instance.md index d97cc2b10..449f5bf71 100644 --- a/docs/resources/instance.md +++ b/docs/resources/instance.md @@ -186,8 +186,14 @@ The following arguments are supported: * `migration_type` - (Optional) The type of migration to use when updating the type or region of a Linode. (`cold`, `warm`; default `cold`) +* `network_helper` - (Optional) Enables the Network Helper feature. The default value is determined by the network_helper setting in the account settings. + * [`interface`](#interface) - (Optional) A list of network interfaces to be assigned to the Linode on creation. If an explicit config or disk is defined, interfaces must be declared in the [`config` block](#configs). +* `interface_generation` - (Optional) Specifies the interface type for the Linode. If set to `linode`, Linode interfaces must be created using a separate resource before this Linode can be booted. (`linode`, `legacy_config`; default is determined by the account `interfaces_for_new_linodes` setting) + +* TODO(Linode Interfaces): Link to a usage example using the `linode_instance_interface` resource + * `firewall_id` - (Optional) The ID of the Firewall to attach to the instance upon creation. *Changing `firewall_id` forces the creation of a new Linode Instance.* * `disk_encryption` - (Optional) The disk encryption policy for this instance. (`enabled`, `disabled`; default `enabled` in supported regions) diff --git a/docs/resources/interface.md b/docs/resources/interface.md new file mode 100644 index 000000000..259f7905c --- /dev/null +++ b/docs/resources/interface.md @@ -0,0 +1,297 @@ +--- +page_title: "Linode: linode_interface" +description: |- + Manages a Linode interface configuration. +--- + +# linode\_interface + +Provides a Linode Interface resource that can be used to create, modify, and delete network interfaces for Linode instances. Interfaces allow you to configure public, VLAN, and VPC networking for your Linode instances. + +This resource is specifically for Linode interfaces. If you are interested in deploying a Linode instance with a legacy config interface, please refer to the `linode_instance_config` resource documentation for details. + +This resource is designed to work with explicitly defined disk and config resources for the Linode instance. See the [Complete Example with Linode](#complete-example-with-linode) section below for details. + +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/post-linode-instance-interface). + +## Example Usage + +### Public Interface Example + +The following example shows how to create a public interface with specific IPv4 and IPv6 configurations. + +```hcl +resource "linode_interface" "public" { + linode_id = linode_instance.my-instance.id + + public = { + ipv4 = { + addresses = [ + { + address = "auto", + primary = true, + } + ] + } + ipv6 = { + ranges = [ + { + range = "/64" + } + ] + } + } +} +``` + +### IPv6-Only Public Interface Example + +The following example shows how to create an IPv6-only public interface. Note that you must explicitly set `addresses = []` to prevent the automatic creation of an IPv4 address. + +```hcl +resource "linode_interface" "ipv6_only" { + linode_id = linode_instance.my-instance.id + + public = { + ipv4 = { + addresses = [] # Empty list prevents auto-creation of IPv4 address + } + ipv6 = { + ranges = [ + { + range = "/64" + } + ] + } + } +} +``` + +### VPC Interface Example + +The following example shows how to create a VPC interface with custom IPv4 configuration and 1:1 NAT. + +```hcl +resource "linode_interface" "vpc" { + linode_id = linode_instance.my-instance.id + + vpc = { + subnet_id = 240213 + ipv4 = { + addresses = [ + { + address = "auto" + } + ] + ranges = [ + { + range = "/32" + } + ] + } + } +} +``` + +### VLAN Interface Example + +The following example shows how to create a VLAN interface. + +```hcl +resource "linode_interface" "vlan" { + linode_id = linode_instance.web.id + + vlan = { + vlan_label = "web-vlan" + ipam_address = "192.168.200.5/24" + } +} +``` + +### Complete Example with Linode + +```hcl +resource "linode_instance" "my-instance" { + label = "my-instance" + region = "us-mia" + type = "g6-standard-1" + interface_generation = "linode" +} + +resource "linode_instance_config" "my-config" { + + # This is necessary to ensure the interface is created + # before the config is booted with the Linode instance + depends_on = [linode_interface.public] + + linode_id = linode_instance.my-instance.id + label = "my-config" + + device { + device_name = "sda" + disk_id = linode_instance_disk.boot.id + } + + booted = true +} + +resource "linode_instance_disk" "boot" { + label = "boot" + linode_id = linode_instance.my-instance.id + size = linode_instance.my-instance.specs.0.disk + + image = "linode/debian12" + root_pass = "this-is-NOT-a-safe-password" +} + +resource "linode_interface" "public" { + linode_id = linode_instance.my-instance.id + public = { + ipv4 = { + addresses = [ + { + address = "auto", + primary = true, + } + ] + } + ipv6 = { + ranges = [ + { + range = "/64" + } + ] + } + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `linode_id` - (Required) The ID of the Linode to assign this interface to. + +* `firewall_id` - (Optional) The ID of an enabled firewall to secure a VPC or public interface. Not allowed for VLAN interfaces. + +* `default_route` - (Optional) Indicates whether the interface serves as the default route when multiple interfaces are eligible for this role. + + * `ipv4` - (Optional) When set to true, the interface is used for the IPv4 default route. + + * `ipv6` - (Optional) When set to true, the interface is used for the IPv6 default route. + +* `public` - (Optional) Configuration for a Linode public interface. Exactly one of `public`, `vlan`, or `vpc` must be specified. + + * `ipv4` - (Optional) IPv4 configuration for this interface. + + * `addresses` - (Optional) IPv4 addresses configured for this Linode interface. Each object in this list supports: + + * `address` - (Optional) The IPv4 address. Defaults to "auto" for automatic assignment. + + * `primary` - (Optional) Whether this address is the primary address for the interface. + + * `ipv6` - (Optional) IPv6 configuration for this interface. + + * `ranges` - (Optional) IPv6 ranges in CIDR notation (2600:0db8::1/64) or prefix-only (/64). Each object in this list supports: + + * `range` - (Required) The IPv6 range. + + * `route_target` - (Optional) The public IPv6 address that the range is routed to. + +* `vlan` - (Optional) Nested attributes object for a Linode VLAN interface. Exactly one of `public`, `vlan`, or `vpc` must be specified. + + * `ipam_address` - (Optional) The VLAN interface's private IPv4 address in CIDR notation. + + * `vlan_label` - (Required) The VLAN's unique label. Must be between 1 and 64 characters. + +* `vpc` - (Optional) Nested attributes object for a Linode VPC interface. Exactly one of `public`, `vlan`, or `vpc` must be specified. + + * `subnet_id` - (Required) The VPC subnet identifier for this interface. + + * `ipv4` - (Optional) IPv4 configuration for the VPC interface. + + * `addresses` - (Optional) Specifies the IPv4 addresses to use in the VPC subnet. Each object in this list supports: + + * `address` - (Optional) The IPv4 address. Defaults to "auto" for automatic assignment. + + * `primary` - (Optional) Whether this address is the primary address for the interface. + + * `nat_1_1_address` - (Optional) The 1:1 NAT IPv4 address used to associate a public IPv4 address with the interface's VPC subnet IPv4 address. + + * `ranges` - (Optional) IPv4 ranges in CIDR notation (1.2.3.4/24) or prefix-only format (/24). Each object in this list supports: + + * `range` - (Required) The IPv4 range. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The unique ID for this interface. + +* `public` - When a public interface is configured, the following computed attributes are available: + + * `ipv4` - IPv4 configuration for the public interface: + + * `assigned_addresses` - (Computed) The IPv4 addresses exclusively assigned to this Linode interface. Each object in this set supports: + + * `address` - The assigned IPv4 address. + + * `primary` - Whether this address is the primary address for the interface. + + * `shared` - (Computed) The IPv4 addresses assigned to this Linode interface that are also shared with another Linode. Each object in this set supports: + + * `address` - The shared IPv4 address. + + * `linode_id` - The ID of the Linode that this address is shared with. + + * `ipv6` - IPv6 configuration for the public interface: + + * `assigned_ranges` - (Computed) The IPv6 ranges exclusively assigned to this Linode interface. Each object in this set supports: + + * `range` - The assigned IPv6 range. + + * `route_target` - The public IPv6 address that the range is routed to. + + * `shared` - (Computed) The IPv6 ranges assigned to this Linode interface that are also shared with another Linode. Each object in this set supports: + + * `range` - The shared IPv6 range. + + * `route_target` - The public IPv6 address that the range is routed to. + + * `slaac` - (Computed) The public SLAAC and subnet prefix settings for this public interface. Each object in this set supports: + + * `address` - The SLAAC IPv6 address. + + * `prefix` - The subnet prefix length. + +* `vpc` - When a VPC interface is configured, the following computed attributes are available: + + * `ipv4` - IPv4 configuration for the VPC interface: + + * `assigned_addresses` - (Computed) The IPv4 addresses assigned for use in the VPC subnet, calculated from the `addresses` input. Each object in this set supports: + + * `address` - The assigned IPv4 address. + + * `primary` - Whether this address is the primary address for the interface. + + * `nat_1_1_address` - The assigned 1:1 NAT IPv4 address used to associate a public IPv4 address with the interface's VPC subnet IPv4 address. + + * `assigned_ranges` - (Computed) The IPv4 ranges assigned for use in the VPC subnet, calculated from the `ranges` input. Each object in this set supports: + + * `range` - The assigned IPv4 range. + +## Import + +Interfaces can be imported using a Linode ID followed by an Interface ID, separated by a comma, e.g. + +```sh +terraform import linode_interface.example 67890,12345 +``` + +## Notes + +* Each Linode instance can have up to 3 network interfaces. +* VLAN interfaces cannot be updated after creation and require recreation. +* VPC subnet IDs cannot be changed after interface creation. +* Firewall IDs are only supported for public and VPC interfaces, not for VLAN interfaces. +* When configuring multiple interfaces, use the `default_route` setting to specify which interface should handle default routing. diff --git a/docs/resources/linode_firewall_settings.md b/docs/resources/linode_firewall_settings.md new file mode 100644 index 000000000..ddb5f6596 --- /dev/null +++ b/docs/resources/linode_firewall_settings.md @@ -0,0 +1,35 @@ +--- +page_title: "Linode: linode_firewall_settings" +description: |- + Manages Linode account-level firewall settings. +--- + +# linode\_firewall\_settings + +Manages Linode account-level firewall settings. Resetting default firewall IDs +to null is not available to all customers and unsupported in this resource. + +## Example Usage + +```hcl +resource "linode_firewall_settings" "example" { + default_firewall_ids = { + linode = 12345 + nodebalancer = 12345 + public_interface = 12345 + vpc_interface = 12345 + } +} +``` + +## Argument Reference + +* `default_firewall_ids` - (Optional) A map of default firewall IDs for various interfaces. + * `linode` - (Optional) The Linode's default firewall. + * `nodebalancer` - (Optional) The NodeBalancer's default firewall. + * `public_interface` - (Optional) The public interface's default firewall. + * `vpc_interface` - (Optional) The VPC interface's default firewall. + +## API Reference + +See the [Linode API documentation](https://techdocs.akamai.com/linode-api/reference/put-firewall-settings) for more details. diff --git a/docs/resources/vpc_subnet.md b/docs/resources/vpc_subnet.md index 11061e2fd..53328b406 100644 --- a/docs/resources/vpc_subnet.md +++ b/docs/resources/vpc_subnet.md @@ -77,7 +77,12 @@ In addition to all the arguments above, the following attributes are exported. * `id` - The ID of the VPC Subnet. -* `linodes` - A list of Linode IDs that added to this subnet. +* `linodes` - A list of Linode that added to this subnet. + * `id` - ID of the Linode + * `interfaces` - A list of networking interfaces objects. + * `id` - ID of the interface. + * `config_id` - ID of Linode Config that the interface is associated with. `null` for a Linode Interface. + * `active` - Whether the Interface is actively in use. * `created` - The date and time when the VPC was created. diff --git a/linode/accountsettings/datasource_test.go b/linode/accountsettings/datasource_test.go index 12e4171d7..37eb68a8b 100644 --- a/linode/accountsettings/datasource_test.go +++ b/linode/accountsettings/datasource_test.go @@ -4,10 +4,12 @@ package accountsettings_test import ( "context" - "strconv" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" "github.com/linode/terraform-provider-linode/v3/linode/acceptance" "github.com/linode/terraform-provider-linode/v3/linode/accountsettings/tmpl" ) @@ -45,14 +47,19 @@ func TestAccDataSourceLinodeAccountSettings_basic(t *testing.T) { Steps: []resource.TestStep{ { Config: tmpl.DataBasic(t), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr(resourceName, "backups_enabled", strconv.FormatBool(settings.BackupsEnabled)), - resource.TestCheckResourceAttr(resourceName, "managed", strconv.FormatBool(settings.Managed)), - resource.TestCheckResourceAttr(resourceName, "network_helper", strconv.FormatBool(settings.NetworkHelper)), - resource.TestCheckResourceAttr(resourceName, "object_storage", objectStorageVal), - resource.TestCheckResourceAttr(resourceName, "longview_subscription", longviewVal), - resource.TestCheckResourceAttr(resourceName, "maintenance_policy", settings.MaintenancePolicy), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("backups_enabled"), knownvalue.Bool(settings.BackupsEnabled)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("managed"), knownvalue.Bool(settings.Managed)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_helper"), knownvalue.Bool(settings.NetworkHelper)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("object_storage"), knownvalue.StringExact(objectStorageVal)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("longview_subscription"), knownvalue.StringExact(longviewVal)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("maintenance_policy"), knownvalue.StringExact(settings.MaintenancePolicy)), + statecheck.ExpectKnownValue( + resourceName, + tfjsonpath.New("interfaces_for_new_linodes"), + knownvalue.StringExact(string(settings.InterfacesForNewLinodes)), + ), + }, }, }, }) diff --git a/linode/accountsettings/framework_datasource_schema.go b/linode/accountsettings/framework_datasource_schema.go index 37d65e5af..e2991f97c 100644 --- a/linode/accountsettings/framework_datasource_schema.go +++ b/linode/accountsettings/framework_datasource_schema.go @@ -30,6 +30,10 @@ var frameworkDataSourceSchema = schema.Schema{ Description: "A string describing the status of this account's Object Storage service enrollment.", Computed: true, }, + "interfaces_for_new_linodes": schema.StringAttribute{ + Description: "Type of interfaces for new Linode instances.", + Computed: true, + }, "maintenance_policy": schema.StringAttribute{ Description: "The default Maintenance Policy for this account.", Computed: true, diff --git a/linode/accountsettings/framework_model.go b/linode/accountsettings/framework_model.go index 4fa650897..f9dde6050 100644 --- a/linode/accountsettings/framework_model.go +++ b/linode/accountsettings/framework_model.go @@ -9,13 +9,14 @@ import ( // AccountSettingsModel describes the Terraform resource data model to match the // resource schema. type AccountSettingsModel struct { - ID types.String `tfsdk:"id"` - LongviewSubscription types.String `tfsdk:"longview_subscription"` - ObjectStorage types.String `tfsdk:"object_storage"` - BackupsEnabled types.Bool `tfsdk:"backups_enabled"` - Managed types.Bool `tfsdk:"managed"` - NetworkHelper types.Bool `tfsdk:"network_helper"` - MaintenancePolicy types.String `tfsdk:"maintenance_policy"` + ID types.String `tfsdk:"id"` + LongviewSubscription types.String `tfsdk:"longview_subscription"` + ObjectStorage types.String `tfsdk:"object_storage"` + InterfacesForNewLinodes types.String `tfsdk:"interfaces_for_new_linodes"` + BackupsEnabled types.Bool `tfsdk:"backups_enabled"` + Managed types.Bool `tfsdk:"managed"` + NetworkHelper types.Bool `tfsdk:"network_helper"` + MaintenancePolicy types.String `tfsdk:"maintenance_policy"` } func (data *AccountSettingsModel) FlattenAccountSettings( @@ -37,6 +38,11 @@ func (data *AccountSettingsModel) FlattenAccountSettings( helper.GetStringPtrWithDefault(settings.ObjectStorage, ""), preserveKnown, ) + data.InterfacesForNewLinodes = helper.KeepOrUpdateString( + data.InterfacesForNewLinodes, + string(settings.InterfacesForNewLinodes), + preserveKnown, + ) data.Managed = helper.KeepOrUpdateBool(data.Managed, settings.Managed, preserveKnown) data.BackupsEnabled = helper.KeepOrUpdateBool( @@ -57,6 +63,9 @@ func (data *AccountSettingsModel) CopyFrom(other AccountSettingsModel, preserveK data.ObjectStorage = helper.KeepOrUpdateValue( data.ObjectStorage, other.ObjectStorage, preserveKnown, ) + data.InterfacesForNewLinodes = helper.KeepOrUpdateValue( + data.InterfacesForNewLinodes, other.InterfacesForNewLinodes, preserveKnown, + ) data.BackupsEnabled = helper.KeepOrUpdateValue( data.BackupsEnabled, other.BackupsEnabled, preserveKnown, ) diff --git a/linode/accountsettings/framework_model_unit_test.go b/linode/accountsettings/framework_model_unit_test.go index 44cb5723e..08e651673 100644 --- a/linode/accountsettings/framework_model_unit_test.go +++ b/linode/accountsettings/framework_model_unit_test.go @@ -20,12 +20,13 @@ func TestFlattenAccountSettings(t *testing.T) { maintenancePolicy := "linode/migrate" mockSettings := &linodego.AccountSettings{ - BackupsEnabled: backupsEnabledValue, - Managed: managedValue, - NetworkHelper: networkHelperValue, - LongviewSubscription: &longviewSubscriptionValue, - ObjectStorage: &objectStorageValue, - MaintenancePolicy: maintenancePolicy, + BackupsEnabled: backupsEnabledValue, + Managed: managedValue, + NetworkHelper: networkHelperValue, + LongviewSubscription: &longviewSubscriptionValue, + ObjectStorage: &objectStorageValue, + InterfacesForNewLinodes: linodego.LinodeDefaultButLegacyConfigAllowed, + MaintenancePolicy: maintenancePolicy, } // Create a mock AccountSettingsModel instance @@ -47,6 +48,10 @@ func TestFlattenAccountSettings(t *testing.T) { t.Errorf("Expected ObjectStorage to be %s, but got %s", "active", model.ObjectStorage) } + if model.InterfacesForNewLinodes != types.StringValue(string(linodego.LinodeDefaultButLegacyConfigAllowed)) { + t.Errorf("Expected InterfacesForNewLinodes to be %s, but got %s", string(linodego.LinodeDefaultButLegacyConfigAllowed), model.InterfacesForNewLinodes) + } + if model.BackupsEnabled != types.BoolValue(true) { t.Errorf("Expected BackupsEnabed to be %v, but got %v", true, model.BackupsEnabled) } diff --git a/linode/accountsettings/framework_resource.go b/linode/accountsettings/framework_resource.go index 012b84122..1c9b90329 100644 --- a/linode/accountsettings/framework_resource.go +++ b/linode/accountsettings/framework_resource.go @@ -42,9 +42,7 @@ func (r *Resource) Create( } // Update the account - resp.Diagnostics.Append( - r.updateAccountSettings(ctx, &plan)..., - ) + r.createOrUpdateAccountSettings(ctx, &plan, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } @@ -116,9 +114,7 @@ func (r *Resource) Update( } // Update the account - resp.Diagnostics.Append( - r.updateAccountSettings(ctx, &plan)..., - ) + r.createOrUpdateAccountSettings(ctx, &plan, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } @@ -144,12 +140,22 @@ func (r *Resource) Delete( tflog.Debug(ctx, "Delete "+r.Config.Name) } -func (r *Resource) updateAccountSettings( +func (r *Resource) createOrUpdateAccountSettings( ctx context.Context, plan *AccountSettingsModel, -) diag.Diagnostics { + diags *diag.Diagnostics, +) { client := r.Meta.Client - var diagnostics diag.Diagnostics + + account, err := client.GetAccount(ctx) + if err != nil { + diags.AddError( + "Failed to get Linode Account", + err.Error(), + ) + return + } + email := account.Email // Longview Plan update functionality has been moved if !plan.LongviewSubscription.IsNull() { @@ -163,18 +169,30 @@ func (r *Resource) updateAccountSettings( _, err := client.UpdateLongviewPlan(ctx, options) if err != nil { - diagnostics.AddError( + diags.AddError( "Failed to update Linode Longview Plan", err.Error(), ) - return diagnostics + return } } - updateOpts := linodego.AccountSettingsUpdateOptions{ - BackupsEnabled: plan.BackupsEnabled.ValueBoolPointer(), - NetworkHelper: plan.NetworkHelper.ValueBoolPointer(), - MaintenancePolicy: plan.MaintenancePolicy.ValueStringPointer(), + updateOpts := linodego.AccountSettingsUpdateOptions{} + + if !plan.BackupsEnabled.IsUnknown() { + updateOpts.BackupsEnabled = plan.BackupsEnabled.ValueBoolPointer() + } + + if !plan.NetworkHelper.IsUnknown() { + updateOpts.NetworkHelper = plan.NetworkHelper.ValueBoolPointer() + } + + if !plan.InterfacesForNewLinodes.IsUnknown() { + updateOpts.InterfacesForNewLinodes = (*linodego.InterfacesForNewLinodes)(plan.InterfacesForNewLinodes.ValueStringPointer()) + } + + if !plan.MaintenancePolicy.IsUnknown() { + updateOpts.MaintenancePolicy = plan.MaintenancePolicy.ValueStringPointer() } tflog.Debug(ctx, "client.UpdateAccountSettings(...)", map[string]any{ @@ -183,10 +201,9 @@ func (r *Resource) updateAccountSettings( settings, err := client.UpdateAccountSettings(ctx, updateOpts) if err != nil { - diagnostics.AddError("Failed to update Linode Account Settings", err.Error()) - return diagnostics + diags.AddError("Failed to update Linode Account Settings", err.Error()) + return } - plan.FlattenAccountSettings(plan.ID.ValueString(), settings, true) - return diagnostics + plan.FlattenAccountSettings(email, settings, true) } diff --git a/linode/accountsettings/framework_resource_schema.go b/linode/accountsettings/framework_resource_schema.go index 0d20f6156..d438e9709 100644 --- a/linode/accountsettings/framework_resource_schema.go +++ b/linode/accountsettings/framework_resource_schema.go @@ -1,10 +1,12 @@ package accountsettings import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) var frameworkResourceSchema = schema.Schema{ @@ -40,7 +42,20 @@ var frameworkResourceSchema = schema.Schema{ stringplanmodifier.UseStateForUnknown(), }, }, - + "interfaces_for_new_linodes": schema.StringAttribute{ + Description: "Type of interfaces for new Linode instances.", + Computed: true, + Optional: true, + Validators: []validator.String{stringvalidator.OneOf( + "legacy_config_only", + "legacy_config_default_but_linode_allowed", + "linode_default_but_legacy_config_allowed", + "linode_only", + )}, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, "managed": schema.BoolAttribute{ Description: "Enables monitoring for connectivity, response, and total request time.", Computed: true, diff --git a/linode/accountsettings/resource_test.go b/linode/accountsettings/resource_test.go index a8bede9e7..1832e85c6 100644 --- a/linode/accountsettings/resource_test.go +++ b/linode/accountsettings/resource_test.go @@ -4,10 +4,13 @@ package accountsettings_test import ( "context" - "strconv" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/acceptance" "github.com/linode/terraform-provider-linode/v3/linode/accountsettings/tmpl" ) @@ -23,12 +26,13 @@ func TestAccResourceAccountSettings_basic(t *testing.T) { Steps: []resource.TestStep{ { Config: tmpl.Basic(t), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(resourceName, "backups_enabled"), - resource.TestCheckResourceAttrSet(resourceName, "managed"), - resource.TestCheckResourceAttrSet(resourceName, "network_helper"), - resource.TestCheckResourceAttrSet(resourceName, "object_storage"), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("backups_enabled"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("managed"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_helper"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("object_storage"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("interfaces_for_new_linodes"), knownvalue.NotNull()), + }, }, }, }) @@ -51,6 +55,7 @@ func TestAccResourceAccountSettings_update(t *testing.T) { currLongviewPlan := longviewSettings.ID currBackupsEnabled := accountSettings.BackupsEnabled currNetworkHelper := accountSettings.NetworkHelper + currInterfacesForNewLinodes := accountSettings.InterfacesForNewLinodes currMaintenancePolicy := accountSettings.MaintenancePolicy updatedLongviewPlan := "longview-10" @@ -58,6 +63,13 @@ func TestAccResourceAccountSettings_update(t *testing.T) { updatedNetworkHelper := !currNetworkHelper updatedMaintenancePolicy := "linode/power_off_on" + var updatedInterfacesForNewLinodes string + if currInterfacesForNewLinodes == linodego.LegacyConfigDefaultButLinodeAllowed { + updatedInterfacesForNewLinodes = string(linodego.LinodeDefaultButLegacyConfigAllowed) + } else { + updatedInterfacesForNewLinodes = string(linodego.LegacyConfigDefaultButLinodeAllowed) + } + if currLongviewPlan == "" || currLongviewPlan == "longview-10" { updatedLongviewPlan = "longview-3" } @@ -71,22 +83,35 @@ func TestAccResourceAccountSettings_update(t *testing.T) { ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, Steps: []resource.TestStep{ { - Config: tmpl.Updates(t, updatedLongviewPlan, updatedBackupsEnabled, updatedNetworkHelper, updatedMaintenancePolicy), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr(resourceName, "longview_subscription", updatedLongviewPlan), - resource.TestCheckResourceAttr(resourceName, "backups_enabled", strconv.FormatBool(updatedBackupsEnabled)), - resource.TestCheckResourceAttr(resourceName, "network_helper", strconv.FormatBool(updatedNetworkHelper)), - resource.TestCheckResourceAttr(resourceName, "maintenance_policy", updatedMaintenancePolicy), + Config: tmpl.Updates( + t, + updatedLongviewPlan, + updatedInterfacesForNewLinodes, + updatedBackupsEnabled, + updatedNetworkHelper, + updatedMaintenancePolicy, ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("longview_subscription"), knownvalue.StringExact(updatedLongviewPlan)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("maintenance_policy"), knownvalue.StringExact(updatedMaintenancePolicy)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("backups_enabled"), knownvalue.Bool(updatedBackupsEnabled)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_helper"), knownvalue.Bool(updatedNetworkHelper)), + statecheck.ExpectKnownValue( + resourceName, tfjsonpath.New("interfaces_for_new_linodes"), knownvalue.StringExact(updatedInterfacesForNewLinodes), + ), + }, }, { - Config: tmpl.Updates(t, currLongviewPlan, currBackupsEnabled, currNetworkHelper, currMaintenancePolicy), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr(resourceName, "longview_subscription", currLongviewPlan), - resource.TestCheckResourceAttr(resourceName, "backups_enabled", strconv.FormatBool(currBackupsEnabled)), - resource.TestCheckResourceAttr(resourceName, "network_helper", strconv.FormatBool(currNetworkHelper)), - resource.TestCheckResourceAttr(resourceName, "maintenance_policy", currMaintenancePolicy), - ), + Config: tmpl.Updates(t, currLongviewPlan, string(currInterfacesForNewLinodes), currBackupsEnabled, currNetworkHelper, currMaintenancePolicy), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("longview_subscription"), knownvalue.StringExact(currLongviewPlan)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("maintenance_policy"), knownvalue.StringExact(currMaintenancePolicy)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("backups_enabled"), knownvalue.Bool(currBackupsEnabled)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("network_helper"), knownvalue.Bool(currNetworkHelper)), + statecheck.ExpectKnownValue( + resourceName, tfjsonpath.New("interfaces_for_new_linodes"), knownvalue.StringExact(string(currInterfacesForNewLinodes)), + ), + }, }, }, }) diff --git a/linode/accountsettings/tmpl/template.go b/linode/accountsettings/tmpl/template.go index 9d276927f..20a6678fa 100644 --- a/linode/accountsettings/tmpl/template.go +++ b/linode/accountsettings/tmpl/template.go @@ -7,10 +7,11 @@ import ( ) type TemplateData struct { - LongviewSubscription string - BackupsEnabled bool - NetworkHelper bool - MaintenancePolicy string + LongviewSubscription string + BackupsEnabled bool + NetworkHelper bool + InterfacesForNewLinodes string + MaintenancePolicy string } func Basic(t testing.TB) string { @@ -23,12 +24,13 @@ func DataBasic(t testing.TB) string { "account_settings_data_basic", nil) } -func Updates(t testing.TB, longviewSubscription string, backupsEnabled, networkHelper bool, maintenancePolicy string) string { +func Updates(t testing.TB, longviewSubscription, interfacesForNewLinodes string, backupsEnabled, networkHelper bool, maintenancePolicy string) string { return acceptance.ExecuteTemplate(t, "account_settings_updates", TemplateData{ - LongviewSubscription: longviewSubscription, - BackupsEnabled: backupsEnabled, - NetworkHelper: networkHelper, - MaintenancePolicy: maintenancePolicy, + LongviewSubscription: longviewSubscription, + BackupsEnabled: backupsEnabled, + NetworkHelper: networkHelper, + InterfacesForNewLinodes: interfacesForNewLinodes, + MaintenancePolicy: maintenancePolicy, }) } diff --git a/linode/accountsettings/tmpl/updates.gotf b/linode/accountsettings/tmpl/updates.gotf index 3d7908311..650e23776 100644 --- a/linode/accountsettings/tmpl/updates.gotf +++ b/linode/accountsettings/tmpl/updates.gotf @@ -4,6 +4,7 @@ resource "linode_account_settings" "foobar" { longview_subscription = "{{ .LongviewSubscription }}" backups_enabled = "{{ .BackupsEnabled }}" network_helper = "{{ .NetworkHelper }}" + interfaces_for_new_linodes = "{{ .InterfacesForNewLinodes }}" maintenance_policy = "{{ .MaintenancePolicy }}" } diff --git a/linode/firewall/firewall_helpers_unit_test.go b/linode/firewall/firewall_helpers_unit_test.go index 7ccc6d27c..e9890e873 100644 --- a/linode/firewall/firewall_helpers_unit_test.go +++ b/linode/firewall/firewall_helpers_unit_test.go @@ -180,9 +180,10 @@ func TestFlattenFirewallRules(t *testing.T) { } for _, c := range cases { - out, err := FlattenFirewallRules(context.Background(), c.rules, nil, false) - if err != nil { - t.Fatal(err) + var diags diag.Diagnostics + out := FlattenFirewallRules(context.Background(), c.rules, nil, false, &diags) + if diags.HasError() { + t.Fatal(diags.Errors()) } for i, rule := range out { if i > len(c.expected) { diff --git a/linode/firewall/framework_datasource.go b/linode/firewall/framework_datasource.go index 8ac36389c..63121afd3 100644 --- a/linode/firewall/framework_datasource.go +++ b/linode/firewall/framework_datasource.go @@ -54,15 +54,6 @@ func (d *DataSource) Read( return } - rules, err := client.GetFirewallRules(ctx, firewallID) - if err != nil { - resp.Diagnostics.AddError( - "Failed to get firewall rules", - err.Error(), - ) - return - } - tflog.Trace(ctx, "client.ListFirewallDevices(...)") devices, err := client.ListFirewallDevices(ctx, firewallID, nil) if err != nil { @@ -73,7 +64,7 @@ func (d *DataSource) Read( return } - resp.Diagnostics.Append(data.flattenFirewallForDataSource(ctx, firewall, devices, rules)...) + resp.Diagnostics.Append(data.flattenFirewallForDataSource(ctx, firewall, devices, firewall.Rules)...) if resp.Diagnostics.HasError() { return } diff --git a/linode/firewall/framework_datasource_test.go b/linode/firewall/framework_datasource_test.go index b80f5c23d..9c44918fd 100644 --- a/linode/firewall/framework_datasource_test.go +++ b/linode/firewall/framework_datasource_test.go @@ -13,6 +13,7 @@ import ( const testFirewallDataName = "data.linode_firewall.test" +// TODO: Add a test case for interfaces when interfaces resource is implemented. func TestAccDataSourceFirewall_basic(t *testing.T) { t.Parallel() diff --git a/linode/firewall/framework_models.go b/linode/firewall/framework_models.go index d36032213..e424c2583 100644 --- a/linode/firewall/framework_models.go +++ b/linode/firewall/framework_models.go @@ -11,8 +11,8 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/helper" ) -// FirewallDataSourceModel describes the Terraform resource data model to match the -// resource schema. +// FirewallDataSourceModel describes the Terraform data source data model to +// match the data source schema. type FirewallDataSourceModel struct { ID types.Int64 `tfsdk:"id"` Label types.String `tfsdk:"label"` @@ -24,6 +24,7 @@ type FirewallDataSourceModel struct { OutboundPolicy types.String `tfsdk:"outbound_policy"` Linodes types.Set `tfsdk:"linodes"` NodeBalancers types.Set `tfsdk:"nodebalancers"` + Interfaces types.Set `tfsdk:"interfaces"` Devices []DeviceModel `tfsdk:"devices"` Status types.String `tfsdk:"status"` Created types.String `tfsdk:"created"` @@ -43,6 +44,7 @@ type FirewallResourceModel struct { OutboundPolicy types.String `tfsdk:"outbound_policy"` Linodes types.Set `tfsdk:"linodes"` NodeBalancers types.Set `tfsdk:"nodebalancers"` + Interfaces types.Set `tfsdk:"interfaces"` Devices types.List `tfsdk:"devices"` Status types.String `tfsdk:"status"` Created timetypes.RFC3339 `tfsdk:"created"` @@ -141,6 +143,11 @@ func (data *FirewallResourceModel) getCreateOptions( return createOpts } + createOpts.Devices.Interfaces = helper.ExpandFwInt64Set(data.Interfaces, diags) + if diags.HasError() { + return createOpts + } + createOpts.Rules = data.ExpandFirewallRuleSet(ctx, diags) if diags.HasError() { return createOpts @@ -190,6 +197,11 @@ func (data *FirewallResourceModel) flattenDevices( return } + data.Interfaces = helper.KeepOrUpdateIntSet(data.Interfaces, AggregateEntityIDs(devices, linodego.FirewallDeviceLinodeInterface), preserveKnown, diags) + if diags.HasError() { + return + } + deviceModels := FlattenFirewallDevices(devices) devicesList, newDiags := types.ListValueFrom(ctx, deviceObjectType, deviceModels) diags.Append(newDiags...) @@ -206,16 +218,14 @@ func (data *FirewallResourceModel) flattenRules( preserveKnown bool, diags *diag.Diagnostics, ) { - inboundRules, newDiags := FlattenFirewallRules(ctx, ruleSet.Inbound, data.Inbound, preserveKnown) - diags.Append(newDiags...) + inboundRules := FlattenFirewallRules(ctx, ruleSet.Inbound, data.Inbound, preserveKnown, diags) if diags.HasError() { return } data.Inbound = inboundRules - outboundRules, newDiags := FlattenFirewallRules(ctx, ruleSet.Outbound, data.Outbound, preserveKnown) - diags.Append(newDiags...) + outboundRules := FlattenFirewallRules(ctx, ruleSet.Outbound, data.Outbound, preserveKnown, diags) if diags.HasError() { return } @@ -249,8 +259,8 @@ func (data *FirewallDataSourceModel) flattenFirewallForDataSource( ctx context.Context, firewall *linodego.Firewall, devices []linodego.FirewallDevice, - ruleSet *linodego.FirewallRuleSet, -) diag.Diagnostics { + ruleSet linodego.FirewallRuleSet, +) (diags diag.Diagnostics) { data.ID = types.Int64Value(int64(firewall.ID)) data.Status = types.StringValue(string(firewall.Status)) data.Created = types.StringValue(firewall.Created.Format(helper.TIME_FORMAT)) @@ -276,6 +286,16 @@ func (data *FirewallDataSourceModel) flattenFirewallForDataSource( } data.NodeBalancers = nodebalancers + interfaces, diags := types.SetValueFrom( + ctx, + types.Int64Type, + AggregateEntityIDs(devices, linodego.FirewallDeviceLinodeInterface), + ) + if diags.HasError() { + return diags + } + data.Interfaces = interfaces + data.Devices = FlattenFirewallDevices(devices) tags, diags := types.SetValueFrom(ctx, types.StringType, firewall.Tags) @@ -289,7 +309,7 @@ func (data *FirewallDataSourceModel) flattenFirewallForDataSource( data.Label = types.StringValue(firewall.Label) if ruleSet.Inbound != nil { - inBound, diags := FlattenFirewallRules(ctx, ruleSet.Inbound, nil, false) + inBound := FlattenFirewallRules(ctx, ruleSet.Inbound, nil, false, &diags) if diags.HasError() { return diags } @@ -297,7 +317,7 @@ func (data *FirewallDataSourceModel) flattenFirewallForDataSource( } if ruleSet.Outbound != nil { - outBound, diags := FlattenFirewallRules(ctx, ruleSet.Outbound, nil, false) + outBound := FlattenFirewallRules(ctx, ruleSet.Outbound, nil, false, &diags) if diags.HasError() { return diags } @@ -319,6 +339,7 @@ func (data *FirewallResourceModel) CopyFrom( data.OutboundPolicy = helper.KeepOrUpdateValue(data.OutboundPolicy, other.OutboundPolicy, preserveKnown) data.Linodes = helper.KeepOrUpdateValue(data.Linodes, other.Linodes, preserveKnown) data.NodeBalancers = helper.KeepOrUpdateValue(data.NodeBalancers, other.NodeBalancers, preserveKnown) + data.Interfaces = helper.KeepOrUpdateValue(data.Interfaces, other.Interfaces, preserveKnown) data.Devices = helper.KeepOrUpdateValue(data.Devices, other.Devices, preserveKnown) data.Status = helper.KeepOrUpdateValue(data.Status, other.Status, preserveKnown) data.Created = helper.KeepOrUpdateValue(data.Created, other.Created, preserveKnown) @@ -353,10 +374,10 @@ func (state *FirewallResourceModel) RulesAndPoliciesHaveChanges( !state.InboundPolicy.Equal(plan.InboundPolicy) || !state.OutboundPolicy.Equal(plan.OutboundPolicy)) } -func (state *FirewallResourceModel) LinodesOrNodeBalancersHaveChanges( +func (state *FirewallResourceModel) LinodesOrNodeBalancersOrInterfacesHaveChanges( ctx context.Context, plan FirewallResourceModel, ) bool { - return !state.Linodes.Equal(plan.Linodes) || !state.NodeBalancers.Equal(plan.NodeBalancers) + return !state.Linodes.Equal(plan.Linodes) || !state.NodeBalancers.Equal(plan.NodeBalancers) || !state.Interfaces.Equal(plan.Interfaces) } func FlattenFirewallRules( @@ -364,9 +385,10 @@ func FlattenFirewallRules( rules []linodego.FirewallRule, knownRules []RuleModel, preserveKnown bool, -) ([]RuleModel, diag.Diagnostics) { + diags *diag.Diagnostics, +) []RuleModel { if preserveKnown && knownRules == nil { - return make([]RuleModel, 0), nil + return make([]RuleModel, 0) } if !preserveKnown { @@ -391,19 +413,19 @@ func FlattenFirewallRules( ipv4, diags := types.ListValueFrom(ctx, types.StringType, rules[i].Addresses.IPv4) if diags.HasError() { - return nil, diags + return nil } knownRules[i].IPv4 = helper.KeepOrUpdateValue(knownRules[i].IPv4, ipv4, preserveKnown) ipv6, diags := types.ListValueFrom(ctx, types.StringType, rules[i].Addresses.IPv6) if diags.HasError() { - return nil, diags + return nil } knownRules[i].IPv6 = helper.KeepOrUpdateValue(knownRules[i].IPv6, ipv6, preserveKnown) } - return knownRules, nil + return knownRules } func AggregateEntityIDs(devices []linodego.FirewallDevice, entityType linodego.FirewallDeviceType) []int { diff --git a/linode/firewall/framework_models_unit_test.go b/linode/firewall/framework_models_unit_test.go index e5262024f..8752b8da4 100644 --- a/linode/firewall/framework_models_unit_test.go +++ b/linode/firewall/framework_models_unit_test.go @@ -37,6 +37,12 @@ func TestParseComputedAttributes(t *testing.T) { Label: "device_entity_2", URL: "test-firewall.example.com", } + deviceEntity3 := linodego.FirewallDeviceEntity{ + ID: 1221, + Type: linodego.FirewallDeviceLinodeInterface, + Label: "device_entity_3", + URL: "test-firewall.example.com", + } devices := []linodego.FirewallDevice{ { ID: 111, @@ -46,6 +52,10 @@ func TestParseComputedAttributes(t *testing.T) { ID: 112, Entity: deviceEntity2, }, + { + ID: 113, + Entity: deviceEntity3, + }, } inboundRules := []linodego.FirewallRule{ @@ -76,7 +86,7 @@ func TestParseComputedAttributes(t *testing.T) { }, } - firewallRules := &linodego.FirewallRuleSet{ + firewallRules := linodego.FirewallRuleSet{ InboundPolicy: "ACCEPT", Inbound: inboundRules, OutboundPolicy: "DROP", @@ -92,6 +102,7 @@ func TestParseComputedAttributes(t *testing.T) { assert.Contains(t, data.Linodes.String(), "1234") assert.Contains(t, data.NodeBalancers.String(), "4321") + assert.Contains(t, data.Interfaces.String(), "1221") assert.Nil(t, diags) diff --git a/linode/firewall/framework_resource.go b/linode/firewall/framework_resource.go index 0a8eb32c8..ca32e7d12 100644 --- a/linode/firewall/framework_resource.go +++ b/linode/firewall/framework_resource.go @@ -196,7 +196,7 @@ func (r *Resource) Update( } } - if state.LinodesOrNodeBalancersHaveChanges(ctx, plan) { + if state.LinodesOrNodeBalancersOrInterfacesHaveChanges(ctx, plan) { linodeIDs := helper.ExpandFwInt64Set(plan.Linodes, &resp.Diagnostics) if resp.Diagnostics.HasError() { return @@ -207,7 +207,12 @@ func (r *Resource) Update( return } - assignments := make([]firewallDeviceAssignment, 0, len(linodeIDs)+len(nodeBalancerIDs)) + interfaceIDs := helper.ExpandFwInt64Set(plan.Interfaces, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + assignments := make([]firewallDeviceAssignment, 0, len(linodeIDs)+len(nodeBalancerIDs)+len(interfaceIDs)) for _, entityID := range linodeIDs { assignments = append(assignments, firewallDeviceAssignment{ ID: entityID, @@ -222,6 +227,13 @@ func (r *Resource) Update( }) } + for _, entityID := range interfaceIDs { + assignments = append(assignments, firewallDeviceAssignment{ + ID: entityID, + Type: linodego.FirewallDeviceLinodeInterface, + }) + } + if err := fwUpdateFirewallDevices(ctx, *client, id, assignments); err != nil { resp.Diagnostics.AddError( fmt.Sprintf("Failed to Update Devices for Firewall %d", id), err.Error(), diff --git a/linode/firewall/framework_resource_test.go b/linode/firewall/framework_resource_test.go index e718f0ba2..abca66971 100644 --- a/linode/firewall/framework_resource_test.go +++ b/linode/firewall/framework_resource_test.go @@ -57,6 +57,8 @@ func sweep(prefix string) error { return nil } +// TODO: Add a test case for interfaces when interfaces resource is implemented. + func TestSmokeTests_firewall(t *testing.T) { tests := []struct { name string diff --git a/linode/firewall/framework_schema_datasource.go b/linode/firewall/framework_schema_datasource.go index c88d56827..bb086a147 100644 --- a/linode/firewall/framework_schema_datasource.go +++ b/linode/firewall/framework_schema_datasource.go @@ -79,6 +79,11 @@ var frameworkDatasourceSchema = schema.Schema{ Description: "The IDs of NodeBalancers assigned to this Firewall.", Computed: true, }, + "interfaces": schema.SetAttribute{ + ElementType: types.Int64Type, + Description: "The IDs of Linode interfaces to apply this firewall to.", + Computed: true, + }, "devices": schema.ListAttribute{ ElementType: deviceObjectType, Description: "The devices associated with this firewall.", diff --git a/linode/firewall/framework_schema_resource.go b/linode/firewall/framework_schema_resource.go index 04301c24a..521f82964 100644 --- a/linode/firewall/framework_schema_resource.go +++ b/linode/firewall/framework_schema_resource.go @@ -159,6 +159,15 @@ var frameworkResourceSchema = schema.Schema{ setplanmodifier.UseStateForUnknown(), }, }, + "interfaces": schema.SetAttribute{ + Description: "The IDs of Linode interfaces to apply this firewall to.", + Optional: true, + Computed: true, + ElementType: types.Int64Type, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + }, "devices": schema.ListAttribute{ Description: "The devices associated with this firewall.", Computed: true, diff --git a/linode/firewalls/datasource_test.go b/linode/firewalls/datasource_test.go deleted file mode 100644 index bcb0edb9f..000000000 --- a/linode/firewalls/datasource_test.go +++ /dev/null @@ -1,88 +0,0 @@ -//go:build integration || firewalls - -package firewalls_test - -import ( - "log" - "testing" - - "github.com/hashicorp/terraform-plugin-testing/helper/acctest" - "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/linode/terraform-provider-linode/v3/linode/acceptance" - "github.com/linode/terraform-provider-linode/v3/linode/firewalls/tmpl" -) - -const testFirewallDataName = "data.linode_firewalls.test" - -var testRegion string - -func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"Linodes"}, "core") - if err != nil { - log.Fatal(err) - } - - testRegion = region -} - -func TestAccDataSourceFirewalls_basic(t *testing.T) { - t.Parallel() - - firewallName := acctest.RandomWithPrefix("tf_test") - acceptance.RunTestWithRetries(t, 3, func(t *acceptance.WrappedT) { - resource.Test(t, resource.TestCase{ - PreCheck: func() { acceptance.PreCheck(t) }, - ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: tmpl.DataAll(t, firewallName, testRegion), - Check: resource.ComposeTestCheckFunc( - acceptance.CheckResourceAttrGreaterThan(testFirewallDataName, "firewalls.#", 0), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.label"), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.tags.#"), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.created"), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.updated"), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.inbound_policy"), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.outbound_policy"), - ), - }, - { - Config: tmpl.DataFilter(t, firewallName, testRegion), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.label", firewallName), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.inbound_policy", "DROP"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.outbound_policy", "ACCEPT"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.disabled", "false"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.status", "enabled"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.tags.#", "2"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.linodes.#", "1"), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.created"), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.updated"), - - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.inbound.0.label", "allow-http"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.inbound.0.action", "ACCEPT"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.inbound.0.protocol", "TCP"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.inbound.0.ports", "80"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.inbound.0.ipv4.#", "1"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.inbound.0.ipv4.0", "0.0.0.0/0"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.inbound.0.ipv6.#", "1"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.inbound.0.ipv6.0", "::/0"), - - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.outbound.0.label", "reject-http"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.outbound.0.action", "DROP"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.outbound.0.protocol", "TCP"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.outbound.0.ports", "80"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.outbound.0.ipv4.#", "1"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.outbound.0.ipv4.0", "0.0.0.0/0"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.outbound.0.ipv6.#", "1"), - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.outbound.0.ipv6.0", "::/0"), - - resource.TestCheckResourceAttr(testFirewallDataName, "firewalls.0.devices.#", "2"), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.devices.0.label"), - resource.TestCheckResourceAttrSet(testFirewallDataName, "firewalls.0.devices.0.type"), - ), - }, - }, - }) - }) -} diff --git a/linode/firewalls/framework_datasource_test.go b/linode/firewalls/framework_datasource_test.go new file mode 100644 index 000000000..6e4de26eb --- /dev/null +++ b/linode/firewalls/framework_datasource_test.go @@ -0,0 +1,232 @@ +//go:build integration || firewalls + +package firewalls_test + +import ( + "log" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/firewalls/tmpl" +) + +const testFirewallDataName = "data.linode_firewalls.test" + +var testRegion string + +func init() { + region, err := acceptance.GetRandomRegionWithCaps([]string{"Linodes"}, "core") + if err != nil { + log.Fatal(err) + } + + testRegion = region +} + +func TestAccDataSourceFirewalls_basic(t *testing.T) { + t.Parallel() + + firewallName := acctest.RandomWithPrefix("tf_test") + acceptance.RunTestWithRetries(t, 3, func(t *acceptance.WrappedT) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.DataAll(t, firewallName, testRegion), + Check: resource.ComposeTestCheckFunc( + acceptance.CheckResourceAttrGreaterThan(testFirewallDataName, "firewalls.#", 0), + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testFirewallDataName, tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("label"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testFirewallDataName, tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("tags"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("created"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("updated"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound_policy"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound_policy"), + knownvalue.NotNull(), + ), + }, + }, + { + Config: tmpl.DataFilter(t, firewallName, testRegion), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("label"), + knownvalue.StringExact(firewallName), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound_policy"), + knownvalue.StringExact("DROP"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound_policy"), + knownvalue.StringExact("ACCEPT"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("disabled"), + knownvalue.Bool(false), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("status"), + knownvalue.StringExact("enabled"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("tags"), + knownvalue.SetSizeExact(2), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("created"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("updated"), + knownvalue.NotNull(), + ), + + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound").AtSliceIndex(0).AtMapKey("label"), + knownvalue.StringExact("allow-http"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound").AtSliceIndex(0).AtMapKey("action"), + knownvalue.StringExact("ACCEPT"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound").AtSliceIndex(0).AtMapKey("protocol"), + knownvalue.StringExact("TCP"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound").AtSliceIndex(0).AtMapKey("ports"), + knownvalue.StringExact("80"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound").AtSliceIndex(0).AtMapKey("ipv4"), + knownvalue.SetSizeExact(1), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound").AtSliceIndex(0).AtMapKey("ipv4").AtSliceIndex(0), + knownvalue.StringExact("0.0.0.0/0"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound").AtSliceIndex(0).AtMapKey("ipv6"), + knownvalue.SetSizeExact(1), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("inbound").AtSliceIndex(0).AtMapKey("ipv6").AtSliceIndex(0), + knownvalue.StringExact("::/0"), + ), + + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound").AtSliceIndex(0).AtMapKey("label"), + knownvalue.StringExact("reject-http"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound").AtSliceIndex(0).AtMapKey("action"), + knownvalue.StringExact("DROP"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound").AtSliceIndex(0).AtMapKey("protocol"), + knownvalue.StringExact("TCP"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound").AtSliceIndex(0).AtMapKey("ports"), + knownvalue.StringExact("80"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound").AtSliceIndex(0).AtMapKey("ipv4"), + knownvalue.SetSizeExact(1), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound").AtSliceIndex(0).AtMapKey("ipv4").AtSliceIndex(0), + knownvalue.StringExact("0.0.0.0/0"), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound").AtSliceIndex(0).AtMapKey("ipv6"), + knownvalue.SetSizeExact(1), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("outbound").AtSliceIndex(0).AtMapKey("ipv6").AtSliceIndex(0), + knownvalue.StringExact("::/0"), + ), + + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("devices"), + knownvalue.SetSizeExact(2), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("devices").AtSliceIndex(0).AtMapKey("label"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("devices").AtSliceIndex(0).AtMapKey("type"), + knownvalue.NotNull(), + ), + + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("linodes"), + knownvalue.SetSizeExact(1), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("nodebalancers"), + knownvalue.SetSizeExact(1), + ), + statecheck.ExpectKnownValue( + testFirewallDataName, + tfjsonpath.New("firewalls").AtSliceIndex(0).AtMapKey("interfaces"), + knownvalue.SetSizeExact(0), + ), + }, + }, + }, + }) + }) +} diff --git a/linode/firewalls/framework_models.go b/linode/firewalls/framework_models.go index 838a0e3d8..35bf72d9a 100644 --- a/linode/firewalls/framework_models.go +++ b/linode/firewalls/framework_models.go @@ -66,6 +66,7 @@ type FirewallModel struct { OutboundPolicy types.String `tfsdk:"outbound_policy"` Linodes []types.Int64 `tfsdk:"linodes"` NodeBalancers []types.Int64 `tfsdk:"nodebalancers"` + Interfaces []types.Int64 `tfsdk:"interfaces"` Status types.String `tfsdk:"status"` Created timetypes.RFC3339 `tfsdk:"created"` Updated timetypes.RFC3339 `tfsdk:"updated"` @@ -92,6 +93,9 @@ func (data *FirewallModel) parseFirewall( data.NodeBalancers = helper.IntSliceToFramework( firewallresource.AggregateEntityIDs(devices, linodego.FirewallDeviceNodeBalancer), ) + data.Interfaces = helper.IntSliceToFramework( + firewallresource.AggregateEntityIDs(devices, linodego.FirewallDeviceLinodeInterface), + ) data.Status = types.StringValue(string(firewall.Status)) data.Created = timetypes.NewRFC3339TimePointerValue(firewall.Created) diff --git a/linode/firewalls/framework_schema.go b/linode/firewalls/framework_schema.go index ad271bfa9..52cb58dee 100644 --- a/linode/firewalls/framework_schema.go +++ b/linode/firewalls/framework_schema.go @@ -109,7 +109,12 @@ var firewallObject = schema.NestedBlockObject{ }, "nodebalancers": schema.SetAttribute{ ElementType: types.Int64Type, - Description: "The IDs of NodeBalancers assigned to this Firewall..", + Description: "The IDs of NodeBalancers assigned to this Firewall.", + Computed: true, + }, + "interfaces": schema.SetAttribute{ + ElementType: types.Int64Type, + Description: "The IDs of Interfaces assigned to this Firewall.", Computed: true, }, "status": schema.StringAttribute{ diff --git a/linode/firewallsettings/framework_datasource.go b/linode/firewallsettings/framework_datasource.go new file mode 100644 index 000000000..a3fa3bab8 --- /dev/null +++ b/linode/firewallsettings/framework_datasource.go @@ -0,0 +1,51 @@ +package firewallsettings + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type DataSource struct { + helper.BaseDataSource +} + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_firewall_settings", + Schema: &frameworkDatasourceSchema, + }, + ), + } +} + +func (d *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + var state FirewallSettingsModel + + client := d.Meta.Client + + resp.Diagnostics.Append(resp.State.Get(ctx, &state)...) + + firewallSettings, err := client.GetFirewallSettings(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Failed to get firewall settings", + "An error occurred while retrieving the firewall settings: "+err.Error(), + ) + return + } + + state.FlattenFirewallSettings(ctx, *firewallSettings, false, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/linode/firewallsettings/framework_datasource_schema.go b/linode/firewallsettings/framework_datasource_schema.go new file mode 100644 index 000000000..f871e25ec --- /dev/null +++ b/linode/firewallsettings/framework_datasource_schema.go @@ -0,0 +1,20 @@ +package firewallsettings + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +var frameworkDatasourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "default_firewall_ids": schema.SingleNestedAttribute{ + Computed: true, + Description: "The default firewall ID for a linode, nodebalancer, public_interface, or vpc_interface.", + Attributes: map[string]schema.Attribute{ + "linode": schema.Int64Attribute{Computed: true}, + "nodebalancer": schema.Int64Attribute{Computed: true}, + "public_interface": schema.Int64Attribute{Computed: true}, + "vpc_interface": schema.Int64Attribute{Computed: true}, + }, + }, + }, +} diff --git a/linode/firewallsettings/framework_datasource_test.go b/linode/firewallsettings/framework_datasource_test.go new file mode 100644 index 000000000..c50573877 --- /dev/null +++ b/linode/firewallsettings/framework_datasource_test.go @@ -0,0 +1,41 @@ +//go:build integration || firewalls + +package firewallsettings_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/firewallsettings/tmpl" +) + +const ( + dataName = "test" + dataFullName = "data.linode_firewall_settings." + dataName +) + +var testRegion string + +func TestAccDataSourceFirewallSettings_basic(t *testing.T) { + t.Parallel() + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.Data(t, dataName), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + dataFullName, + tfjsonpath.New("default_firewall_ids"), + knownvalue.NotNull(), + ), + }, + }, + }, + }) +} diff --git a/linode/firewallsettings/framework_model_unit_test.go b/linode/firewallsettings/framework_model_unit_test.go new file mode 100644 index 000000000..cc941cda6 --- /dev/null +++ b/linode/firewallsettings/framework_model_unit_test.go @@ -0,0 +1,139 @@ +//go:build unit + +package firewallsettings_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/firewallsettings" + "github.com/stretchr/testify/assert" +) + +func TestFlattenFirewallSettings(t *testing.T) { + ctx := context.Background() + defaultFirewallIDsObjectAttrType := firewallsettings.FrameworkResourceSchema.Attributes["default_firewall_ids"].(schema.SingleNestedAttribute).GetType().(types.ObjectType).AttrTypes + firewallSettings := linodego.FirewallSettings{ + DefaultFirewallIDs: linodego.DefaultFirewallIDs{ + Linode: linodego.Pointer(123), + NodeBalancer: nil, + PublicInterface: linodego.Pointer(789), + VPCInterface: nil, + }, + } + + expectedModelWhenNotPreservingKnown := firewallsettings.FirewallSettingsModel{ + DefaultFirewallIDs: types.ObjectValueMust( + defaultFirewallIDsObjectAttrType, + map[string]attr.Value{ + "linode": types.Int64Value(123), + "nodebalancer": types.Int64Null(), + "public_interface": types.Int64Value(789), + "vpc_interface": types.Int64Null(), + }, + ), + } + + tests := map[string]struct { + model firewallsettings.FirewallSettingsModel + settings linodego.FirewallSettings + expected firewallsettings.FirewallSettingsModel + preserveKnown bool + }{ + "unknown default firewall IDs with preserving known": { + model: firewallsettings.FirewallSettingsModel{ + DefaultFirewallIDs: types.ObjectUnknown(defaultFirewallIDsObjectAttrType), + }, + settings: firewallSettings, + expected: expectedModelWhenNotPreservingKnown, + preserveKnown: true, + }, + "null default firewall IDs with preserving known": { + model: firewallsettings.FirewallSettingsModel{ + DefaultFirewallIDs: types.ObjectNull(defaultFirewallIDsObjectAttrType), + }, + settings: firewallSettings, + expected: firewallsettings.FirewallSettingsModel{ + DefaultFirewallIDs: types.ObjectNull(defaultFirewallIDsObjectAttrType), + }, + preserveKnown: true, + }, + "known default firewall IDs with preserving known": { + model: firewallsettings.FirewallSettingsModel{ + DefaultFirewallIDs: types.ObjectValueMust( + defaultFirewallIDsObjectAttrType, + map[string]attr.Value{ + "linode": types.Int64Value(123), + "nodebalancer": types.Int64Value(456), + "public_interface": types.Int64Unknown(), + "vpc_interface": types.Int64Unknown(), + }, + ), + }, + settings: firewallSettings, + expected: firewallsettings.FirewallSettingsModel{ + DefaultFirewallIDs: types.ObjectValueMust( + defaultFirewallIDsObjectAttrType, + map[string]attr.Value{ + "linode": types.Int64Value(123), + "nodebalancer": types.Int64Value(456), + "public_interface": types.Int64Value(789), + "vpc_interface": types.Int64Null(), + }, + ), + }, + preserveKnown: true, + }, + "unknown default firewall IDs without preserving known": { + model: firewallsettings.FirewallSettingsModel{ + DefaultFirewallIDs: types.ObjectUnknown(defaultFirewallIDsObjectAttrType), + }, + settings: firewallSettings, + expected: expectedModelWhenNotPreservingKnown, + preserveKnown: false, + }, + "null default firewall IDs without preserving known": { + model: firewallsettings.FirewallSettingsModel{ + DefaultFirewallIDs: types.ObjectNull(defaultFirewallIDsObjectAttrType), + }, + settings: firewallSettings, + expected: expectedModelWhenNotPreservingKnown, + preserveKnown: false, + }, + "known default firewall IDs without preserving known": { + model: firewallsettings.FirewallSettingsModel{ + DefaultFirewallIDs: types.ObjectValueMust( + defaultFirewallIDsObjectAttrType, + map[string]attr.Value{ + "linode": types.Int64Value(123), + "nodebalancer": types.Int64Value(456), + "public_interface": types.Int64Unknown(), + "vpc_interface": types.Int64Unknown(), + }, + ), + }, + settings: firewallSettings, + expected: expectedModelWhenNotPreservingKnown, + preserveKnown: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + diags := &diag.Diagnostics{} + tt.model.FlattenFirewallSettings(ctx, tt.settings, tt.preserveKnown, diags) + + if diags.HasError() { + t.Fatalf("unexpected error: %v", diags) + } + + assert.Equal(t, tt.expected.DefaultFirewallIDs, tt.model.DefaultFirewallIDs, + "Flattened DefaultFirewallIDs should match expected value") + }) + } +} diff --git a/linode/firewallsettings/framework_models.go b/linode/firewallsettings/framework_models.go new file mode 100644 index 000000000..6cbdb1e4e --- /dev/null +++ b/linode/firewallsettings/framework_models.go @@ -0,0 +1,113 @@ +package firewallsettings + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type DefaultFirewallIDsAttributeModel struct { + Linode types.Int64 `tfsdk:"linode"` + NodeBalancer types.Int64 `tfsdk:"nodebalancer"` + PublicInterface types.Int64 `tfsdk:"public_interface"` + VPCInterface types.Int64 `tfsdk:"vpc_interface"` +} + +type FirewallSettingsModel struct { + DefaultFirewallIDs types.Object `tfsdk:"default_firewall_ids"` +} + +func (fsds *FirewallSettingsModel) GetUpdateOptions( + ctx context.Context, + diags *diag.Diagnostics, +) (opts linodego.FirewallSettingsUpdateOptions) { + var defaultFirewallIDsModel DefaultFirewallIDsAttributeModel + var defaultFirewallIDs linodego.DefaultFirewallIDsOptions + + diags.Append(fsds.DefaultFirewallIDs.As(ctx, &defaultFirewallIDsModel, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: false, + UnhandledUnknownAsEmpty: false, + })...) + + if diags.HasError() { + return opts + } + + shouldUpdateDefaultFirewallIDs := false + + if !defaultFirewallIDsModel.Linode.IsUnknown() { + defaultFirewallIDs.Linode = linodego.Pointer( + helper.FrameworkSafeInt64PointerToIntPointer(defaultFirewallIDsModel.Linode.ValueInt64Pointer(), diags), + ) + shouldUpdateDefaultFirewallIDs = true + } + + if !defaultFirewallIDsModel.NodeBalancer.IsUnknown() { + defaultFirewallIDs.NodeBalancer = linodego.Pointer( + helper.FrameworkSafeInt64PointerToIntPointer(defaultFirewallIDsModel.NodeBalancer.ValueInt64Pointer(), diags), + ) + shouldUpdateDefaultFirewallIDs = true + } + + if !defaultFirewallIDsModel.PublicInterface.IsUnknown() { + defaultFirewallIDs.PublicInterface = linodego.Pointer( + helper.FrameworkSafeInt64PointerToIntPointer(defaultFirewallIDsModel.PublicInterface.ValueInt64Pointer(), diags), + ) + shouldUpdateDefaultFirewallIDs = true + } + + if !defaultFirewallIDsModel.VPCInterface.IsUnknown() { + defaultFirewallIDs.VPCInterface = linodego.Pointer( + helper.FrameworkSafeInt64PointerToIntPointer(defaultFirewallIDsModel.VPCInterface.ValueInt64Pointer(), diags), + ) + shouldUpdateDefaultFirewallIDs = true + } + + if diags.HasError() { + return opts + } + + if shouldUpdateDefaultFirewallIDs { + opts.DefaultFirewallIDs = &defaultFirewallIDs + } + + return opts +} + +func (fsds *FirewallSettingsModel) FlattenFirewallSettings( + ctx context.Context, + settings linodego.FirewallSettings, + preserveKnown bool, + diags *diag.Diagnostics, +) { + defaultFirewallIDs := helper.KeepOrUpdateSingleNestedAttributes( + ctx, + fsds.DefaultFirewallIDs, + preserveKnown, + diags, + func(defaultFirewallIDsAttrsModel *DefaultFirewallIDsAttributeModel, _ *bool, preserveKnown bool, _ *diag.Diagnostics) { + defaultFirewallIDsAttrsModel.FlattenDefaultFirewallIDs(settings, preserveKnown) + }, + ) + + if diags.HasError() { + return + } + + fsds.DefaultFirewallIDs = *defaultFirewallIDs +} + +func (dfiam *DefaultFirewallIDsAttributeModel) FlattenDefaultFirewallIDs(settings linodego.FirewallSettings, preserveKnown bool) { + dfiam.Linode = helper.KeepOrUpdateInt64Pointer(dfiam.Linode, helper.IntPtrToInt64Ptr(settings.DefaultFirewallIDs.Linode), preserveKnown) + dfiam.NodeBalancer = helper.KeepOrUpdateInt64Pointer(dfiam.NodeBalancer, helper.IntPtrToInt64Ptr(settings.DefaultFirewallIDs.NodeBalancer), preserveKnown) + dfiam.PublicInterface = helper.KeepOrUpdateInt64Pointer( + dfiam.PublicInterface, + helper.IntPtrToInt64Ptr(settings.DefaultFirewallIDs.PublicInterface), + preserveKnown, + ) + dfiam.VPCInterface = helper.KeepOrUpdateInt64Pointer(dfiam.VPCInterface, helper.IntPtrToInt64Ptr(settings.DefaultFirewallIDs.VPCInterface), preserveKnown) +} diff --git a/linode/firewallsettings/framework_resource.go b/linode/firewallsettings/framework_resource.go new file mode 100644 index 000000000..7dba433c5 --- /dev/null +++ b/linode/firewallsettings/framework_resource.go @@ -0,0 +1,158 @@ +package firewallsettings + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "linode_firewall_settings", + Schema: &FrameworkResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + tflog.Debug(ctx, "Create "+r.Config.Name) + var plan FirewallSettingsModel + client := r.Meta.Client + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + updateFirewallSettings(ctx, client, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + // IDs should always be overridden during creation (see #1085) + // TODO: Remove when Crossplane empty string ID issue is resolved + // plan.ID = types.StringValue(strconv.Itoa(firewall.ID)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + tflog.Debug(ctx, "Read "+r.Config.Name) + + client := r.Meta.Client + + var state FirewallSettingsModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + firewallSettings, err := client.GetFirewallSettings(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Failed to get firewall settings", + fmt.Sprintf( + "An error occurred while retrieving the firewall settings: %s", + err.Error(), + ), + ) + return + } + + state.FlattenFirewallSettings(ctx, *firewallSettings, false, &resp.Diagnostics) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + tflog.Debug(ctx, "Update "+r.Config.Name) + + client := r.Meta.Client + var plan FirewallSettingsModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + updateFirewallSettings(ctx, client, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func updateFirewallSettings( + ctx context.Context, + client *linodego.Client, + plan *FirewallSettingsModel, + diags *diag.Diagnostics, +) { + tflog.Debug(ctx, "Updating firewall settings") + + updateOptions := plan.GetUpdateOptions(ctx, diags) + if diags.HasError() { + return + } + + firewallSettings, err := client.UpdateFirewallSettings(ctx, updateOptions) + if err != nil { + diags.AddError( + "Failed to update firewall settings", + fmt.Sprintf("An error occurred while updating the firewall settings: %s", err.Error()), + ) + return + } + + plan.FlattenFirewallSettings(ctx, *firewallSettings, true, diags) +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + tflog.Debug(ctx, "Delete "+r.Config.Name) + tflog.Info( + ctx, "Firewall settings cannot be deleted. "+ + "The TF state has been deleted, but the "+ + "firewall settings will remain in Linode's system.", + ) +} + +func (r *Resource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + resp.Diagnostics.AddError( + "Resource Import Not Supported", + "Importing state for this resource is not supported. "+ + "Please use the resource to manage firewall settings directly for your account.", + ) +} diff --git a/linode/firewallsettings/framework_resource_schema.go b/linode/firewallsettings/framework_resource_schema.go new file mode 100644 index 000000000..01b9bf480 --- /dev/null +++ b/linode/firewallsettings/framework_resource_schema.go @@ -0,0 +1,50 @@ +package firewallsettings + +import ( + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +var FrameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "default_firewall_ids": schema.SingleNestedAttribute{ + Optional: true, + Description: "The default firewall ID for a linode, nodebalancer, public_interface, or vpc_interface.", + Attributes: map[string]schema.Attribute{ + "linode": schema.Int64Attribute{ + Description: "The Linode's default firewall.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Optional: true, + Computed: true, + }, + "nodebalancer": schema.Int64Attribute{ + Description: "The NodeBalancer's default firewall.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Optional: true, + Computed: true, + }, + "public_interface": schema.Int64Attribute{ + Description: "The public interface's default firewall.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Optional: true, + Computed: true, + }, + "vpc_interface": schema.Int64Attribute{ + Description: "The VPC interface's default firewall.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Optional: true, + Computed: true, + }, + }, + }, + }, +} diff --git a/linode/firewallsettings/framework_resource_test.go b/linode/firewallsettings/framework_resource_test.go new file mode 100644 index 000000000..1640d9fbe --- /dev/null +++ b/linode/firewallsettings/framework_resource_test.go @@ -0,0 +1,108 @@ +//go:build integration || firewallsettings + +package firewallsettings_test + +import ( + "context" + "log" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/firewallsettings/tmpl" +) + +var ( + originalFirewallSettings *linodego.FirewallSettings + testFirewallID int +) + +const ( + resourceName = "test" + resourceFullName = "linode_firewall_settings." + resourceName +) + +func init() { + client, err := acceptance.GetTestClient() + if err != nil { + log.Fatalf("failed to get client: %s", err) + } + + originalFirewallSettings, err = client.GetFirewallSettings(context.Background()) + if err != nil { + log.Fatalf("failed to get firewall settings: %s", err) + } + + testFirewall, err := client.CreateFirewall(context.Background(), linodego.FirewallCreateOptions{ + Label: acctest.RandomWithPrefix("tf_test"), + Rules: linodego.FirewallRuleSet{ + InboundPolicy: "DROP", + OutboundPolicy: "ACCEPT", + }, + }) + if err != nil { + log.Fatalf("failed to setup test firewall: %s", err) + } + + testFirewallID = testFirewall.ID + time.Sleep(2 * time.Second) // Wait for the firewall to be fully created +} + +func TestAccResourceFirewallSettings_basic(t *testing.T) { + t.Parallel() + t.Cleanup(func() { + client, err := acceptance.GetTestClient() + if err != nil { + log.Fatalf("failed to get client: %s", err) + } + + if originalFirewallSettings != nil { + _, err = client.UpdateFirewallSettings(context.Background(), linodego.FirewallSettingsUpdateOptions{ + DefaultFirewallIDs: &linodego.DefaultFirewallIDsOptions{ + Linode: linodego.Pointer(originalFirewallSettings.DefaultFirewallIDs.Linode), + NodeBalancer: linodego.Pointer(originalFirewallSettings.DefaultFirewallIDs.NodeBalancer), + PublicInterface: linodego.Pointer(originalFirewallSettings.DefaultFirewallIDs.PublicInterface), + VPCInterface: linodego.Pointer(originalFirewallSettings.DefaultFirewallIDs.VPCInterface), + }, + }) + if err != nil { + log.Fatalf("failed to restore original firewall settings: %s", err) + } + } + + err = client.DeleteFirewall(context.Background(), testFirewallID) + if err != nil { + log.Fatalf("failed to delete test firewall: %s", err) + } + }) + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.Basic(t, resourceName, testFirewallID), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceFullName, tfjsonpath.New("default_firewall_ids"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + resourceFullName, tfjsonpath.New("default_firewall_ids").AtMapKey("linode"), knownvalue.Int64Exact(int64(testFirewallID)), + ), + statecheck.ExpectKnownValue( + resourceFullName, tfjsonpath.New("default_firewall_ids").AtMapKey("nodebalancer"), knownvalue.Int64Exact(int64(testFirewallID)), + ), + statecheck.ExpectKnownValue( + resourceFullName, tfjsonpath.New("default_firewall_ids").AtMapKey("public_interface"), knownvalue.Int64Exact(int64(testFirewallID)), + ), + statecheck.ExpectKnownValue( + resourceFullName, tfjsonpath.New("default_firewall_ids").AtMapKey("vpc_interface"), knownvalue.Int64Exact(int64(testFirewallID)), + ), + }, + }, + }, + }) +} diff --git a/linode/firewallsettings/tmpl/basic.gotf b/linode/firewallsettings/tmpl/basic.gotf new file mode 100644 index 000000000..cbd537d0d --- /dev/null +++ b/linode/firewallsettings/tmpl/basic.gotf @@ -0,0 +1,12 @@ +{{ define "linode_firewall_settings_basic" }} + +resource "linode_firewall_settings" "{{ .ResourceName }}" { + default_firewall_ids = { + linode = {{ .FirewallID }} + nodebalancer = {{ .FirewallID }} + public_interface = {{ .FirewallID }} + vpc_interface = {{ .FirewallID }} + } +} + +{{ end }} diff --git a/linode/firewallsettings/tmpl/data.gotf b/linode/firewallsettings/tmpl/data.gotf new file mode 100644 index 000000000..25b95dd06 --- /dev/null +++ b/linode/firewallsettings/tmpl/data.gotf @@ -0,0 +1,6 @@ +{{ define "data_linode_firewall_settings" }} + +data "linode_firewall_settings" "{{ .DataSourceName }}" { +} + +{{ end }} \ No newline at end of file diff --git a/linode/firewallsettings/tmpl/template.go b/linode/firewallsettings/tmpl/template.go new file mode 100644 index 000000000..92c9b5828 --- /dev/null +++ b/linode/firewallsettings/tmpl/template.go @@ -0,0 +1,26 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + DataSourceName string + ResourceName string + FirewallID int +} + +func Basic(t testing.TB, resourceName string, testFirewallID int) string { + return acceptance.ExecuteTemplate(t, "linode_firewall_settings_basic", TemplateData{ + ResourceName: resourceName, + FirewallID: testFirewallID, + }) +} + +func Data(t testing.TB, datasourceName string) string { + return acceptance.ExecuteTemplate(t, "data_linode_firewall_settings", TemplateData{ + DataSourceName: datasourceName, + }) +} diff --git a/linode/firewalltemplate/framework_datasource.go b/linode/firewalltemplate/framework_datasource.go new file mode 100644 index 000000000..ec9f4d149 --- /dev/null +++ b/linode/firewalltemplate/framework_datasource.go @@ -0,0 +1,63 @@ +package firewalltemplate + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type DataSource struct { + helper.BaseDataSource +} + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_firewall_template", + Schema: &frameworkDatasourceSchema, + }, + ), + } +} + +func (d *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+d.Config.Name) + + var data FirewallTemplateDataSourceModel + client := d.Meta.Client + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + slug := data.Slug.ValueString() + ctx = tflog.SetField(ctx, "slug", slug) + + tflog.Trace(ctx, "client.GetFirewallTemplate(...)", map[string]any{ + "slug": slug, + }) + firewallTemplate, err := client.GetFirewallTemplate(ctx, slug) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to Get the Firewall Template %q", slug), + err.Error(), + ) + return + } + + data.parseFirewallTemplate(ctx, *firewallTemplate, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/linode/firewalltemplate/framework_datasource_test.go b/linode/firewalltemplate/framework_datasource_test.go new file mode 100644 index 000000000..5dcef3a2a --- /dev/null +++ b/linode/firewalltemplate/framework_datasource_test.go @@ -0,0 +1,41 @@ +//go:build integration || firewalltemplate + +package firewalltemplate_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/firewalltemplate/tmpl" +) + +const testTemplateDataName = "data.linode_firewall_template.test" + +func TestAccDataSourceFirewalls_basic(t *testing.T) { + t.Parallel() + testSlug := "public" + + acceptance.RunTestWithRetries(t, 3, func(t *acceptance.WrappedT) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, testSlug), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testTemplateDataName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testTemplateDataName, tfjsonpath.New("slug"), knownvalue.StringExact(testSlug)), + statecheck.ExpectKnownValue(testTemplateDataName, tfjsonpath.New("inbound"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testTemplateDataName, tfjsonpath.New("inbound_policy"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testTemplateDataName, tfjsonpath.New("outbound"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testTemplateDataName, tfjsonpath.New("outbound_policy"), knownvalue.NotNull()), + }, + }, + }, + }) + }) +} diff --git a/linode/firewalltemplate/framework_models.go b/linode/firewalltemplate/framework_models.go new file mode 100644 index 000000000..9def7727b --- /dev/null +++ b/linode/firewalltemplate/framework_models.go @@ -0,0 +1,59 @@ +package firewalltemplate + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/firewall" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +// FirewallTemplateDataSourceModel describes the Terraform +// resource data model to match the resource schema. +type FirewallTemplateBaseModel struct { + Slug types.String `tfsdk:"slug"` + Inbound []firewall.RuleModel `tfsdk:"inbound"` + InboundPolicy types.String `tfsdk:"inbound_policy"` + Outbound []firewall.RuleModel `tfsdk:"outbound"` + OutboundPolicy types.String `tfsdk:"outbound_policy"` +} + +// FirewallTemplateDataSourceModel describes the Terraform +// resource data model to match the resource schema. +type FirewallTemplateDataSourceModel struct { + FirewallTemplateBaseModel + ID types.String `tfsdk:"id"` +} + +func (data *FirewallTemplateBaseModel) FlattenFirewallTemplate( + ctx context.Context, + template linodego.FirewallTemplate, + diags *diag.Diagnostics, + preserveKnown bool, +) { + data.Slug = helper.KeepOrUpdateString(data.Slug, template.Slug, preserveKnown) + + data.Inbound = firewall.FlattenFirewallRules(ctx, template.Rules.Inbound, nil, false, diags) + if diags.HasError() { + return + } + + data.Outbound = firewall.FlattenFirewallRules(ctx, template.Rules.Outbound, nil, false, diags) + if diags.HasError() { + return + } + + data.InboundPolicy = helper.KeepOrUpdateString(data.InboundPolicy, template.Rules.InboundPolicy, preserveKnown) + data.OutboundPolicy = helper.KeepOrUpdateString(data.OutboundPolicy, template.Rules.OutboundPolicy, preserveKnown) +} + +func (data *FirewallTemplateDataSourceModel) parseFirewallTemplate( + ctx context.Context, + template linodego.FirewallTemplate, + diags *diag.Diagnostics, +) { + data.ID = helper.KeepOrUpdateString(data.ID, template.Slug, false) + data.FlattenFirewallTemplate(ctx, template, diags, false) +} diff --git a/linode/firewalltemplate/framework_schema.go b/linode/firewalltemplate/framework_schema.go new file mode 100644 index 000000000..c36426d82 --- /dev/null +++ b/linode/firewalltemplate/framework_schema.go @@ -0,0 +1,39 @@ +package firewalltemplate + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/linode/terraform-provider-linode/v3/linode/firewall" +) + +var frameworkDatasourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The computed ID of the data source should have the same value as the `slug` attribute.", + Computed: true, + }, + "slug": schema.StringAttribute{ + Description: "The slug of the firewall template.", + Required: true, + }, + "inbound": schema.ListAttribute{ + ElementType: firewall.RuleObjectType, + Description: "A firewall rule that specifies what inbound network traffic is allowed.", + Computed: true, + }, + "inbound_policy": schema.StringAttribute{ + Description: "The default behavior for inbound traffic. This setting can be overridden by updating " + + "the inbound.action property for an individual Firewall Rule.", + Computed: true, + }, + "outbound": schema.ListAttribute{ + ElementType: firewall.RuleObjectType, + Description: "A firewall rule that specifies what outbound network traffic is allowed.", + Computed: true, + }, + "outbound_policy": schema.StringAttribute{ + Description: "The default behavior for outbound traffic. This setting can be overridden by updating " + + "the outbound.action property for an individual Firewall Rule.", + Computed: true, + }, + }, +} diff --git a/linode/firewalltemplate/tmpl/data_basic.gotf b/linode/firewalltemplate/tmpl/data_basic.gotf new file mode 100644 index 000000000..f3fa451be --- /dev/null +++ b/linode/firewalltemplate/tmpl/data_basic.gotf @@ -0,0 +1,7 @@ +{{ define "data_linode_firewall_template_basic" }} + +data "linode_firewall_template" "test" { + slug = "public" +} + +{{ end }} diff --git a/linode/firewalltemplate/tmpl/template.go b/linode/firewalltemplate/tmpl/template.go new file mode 100644 index 000000000..deb0c7042 --- /dev/null +++ b/linode/firewalltemplate/tmpl/template.go @@ -0,0 +1,18 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + Slug string +} + +func DataBasic(t testing.TB, slug string) string { + return acceptance.ExecuteTemplate(t, + "data_linode_firewall_template_basic", TemplateData{ + Slug: slug, + }) +} diff --git a/linode/firewalltemplates/framework_datasource.go b/linode/firewalltemplates/framework_datasource.go new file mode 100644 index 000000000..80781fd89 --- /dev/null +++ b/linode/firewalltemplates/framework_datasource.go @@ -0,0 +1,85 @@ +package firewalltemplates + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type DataSource struct { + helper.BaseDataSource +} + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_firewall_templates", + Schema: &frameworkDatasourceSchema, + }, + ), + } +} + +func (d *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+d.Config.Name) + + var data FirewallTemplateFilterModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + id, diag := filterConfig.GenerateID(data.Filters) + if diag != nil { + resp.Diagnostics.Append(diag) + return + } + data.ID = id + + result, diag := filterConfig.GetAndFilter( + ctx, d.Meta.Client, data.Filters, listFirewallTemplates, + types.StringNull(), types.StringNull()) + if diag != nil { + resp.Diagnostics.Append(diag) + return + } + + data.parseFirewallTemplates( + ctx, + helper.AnySliceToTyped[linodego.FirewallTemplate](result), + &resp.Diagnostics, + ) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func listFirewallTemplates( + ctx context.Context, + client *linodego.Client, + filter string, +) ([]any, error) { + tflog.Trace(ctx, "client.ListFirewallTemplates(...)", map[string]any{ + "filter": filter, + }) + firewalls, err := client.ListFirewallTemplates(ctx, &linodego.ListOptions{ + Filter: filter, + }) + if err != nil { + return nil, err + } + + return helper.TypedSliceToAny(firewalls), nil +} diff --git a/linode/firewalltemplates/framework_datasource_test.go b/linode/firewalltemplates/framework_datasource_test.go new file mode 100644 index 000000000..bee65e139 --- /dev/null +++ b/linode/firewalltemplates/framework_datasource_test.go @@ -0,0 +1,68 @@ +//go:build integration || firewalltemplates + +package firewalltemplates_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/firewalltemplates/tmpl" +) + +const testTemplatesDataName = "data.linode_firewall_templates.test" + +func TestAccDataSourceFirewalls_basic(t *testing.T) { + t.Parallel() + testSlug := "public" + + acceptance.RunTestWithRetries(t, 3, func(t *acceptance.WrappedT) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testTemplatesDataName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testTemplatesDataName, tfjsonpath.New("firewall_templates"), knownvalue.NotNull()), + }, + }, + { + Config: tmpl.DataFilter(t, testSlug), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testTemplatesDataName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testTemplatesDataName, + tfjsonpath.New("firewall_templates").AtSliceIndex(0).AtMapKey("slug"), + knownvalue.StringExact(testSlug), + ), + statecheck.ExpectKnownValue( + testTemplatesDataName, + tfjsonpath.New("firewall_templates").AtSliceIndex(0).AtMapKey("inbound"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testTemplatesDataName, + tfjsonpath.New("firewall_templates").AtSliceIndex(0).AtMapKey("inbound_policy"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testTemplatesDataName, + tfjsonpath.New("firewall_templates").AtSliceIndex(0).AtMapKey("outbound"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testTemplatesDataName, + tfjsonpath.New("firewall_templates").AtSliceIndex(0).AtMapKey("outbound_policy"), + knownvalue.NotNull(), + ), + }, + }, + }, + }) + }) +} diff --git a/linode/firewalltemplates/framework_models.go b/linode/firewalltemplates/framework_models.go new file mode 100644 index 000000000..5792eddc4 --- /dev/null +++ b/linode/firewalltemplates/framework_models.go @@ -0,0 +1,28 @@ +package firewalltemplates + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/firewalltemplate" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +type FirewallTemplateFilterModel struct { + ID types.String `tfsdk:"id"` + Filters frameworkfilter.FiltersModelType `tfsdk:"filter"` + FirewallTemplates []firewalltemplate.FirewallTemplateBaseModel `tfsdk:"firewall_templates"` +} + +func (data *FirewallTemplateFilterModel) parseFirewallTemplates( + ctx context.Context, + templates []linodego.FirewallTemplate, + diags *diag.Diagnostics, +) { + data.FirewallTemplates = make([]firewalltemplate.FirewallTemplateBaseModel, len(templates)) + for i, t := range templates { + data.FirewallTemplates[i].FlattenFirewallTemplate(ctx, t, diags, false) + } +} diff --git a/linode/firewalltemplates/framework_schema.go b/linode/firewalltemplates/framework_schema.go new file mode 100644 index 000000000..8f5a626b8 --- /dev/null +++ b/linode/firewalltemplates/framework_schema.go @@ -0,0 +1,57 @@ +package firewalltemplates + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/linode/terraform-provider-linode/v3/linode/firewall" + "github.com/linode/terraform-provider-linode/v3/linode/helper" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +var filterConfig = frameworkfilter.Config{ + "slug": {APIFilterable: false, TypeFunc: helper.FilterTypeString}, +} + +var templatesObject = schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "slug": schema.StringAttribute{ + Description: "The slug of the firewall template.", + Computed: true, + }, + "inbound": schema.ListAttribute{ + ElementType: firewall.RuleObjectType, + Description: "A firewall rule that specifies what inbound network traffic is allowed.", + Computed: true, + }, + "inbound_policy": schema.StringAttribute{ + Description: "The default behavior for inbound traffic. This setting can be overridden by updating " + + "the inbound.action property for an individual Firewall Rule.", + Computed: true, + }, + "outbound": schema.ListAttribute{ + ElementType: firewall.RuleObjectType, + Description: "A firewall rule that specifies what outbound network traffic is allowed.", + Computed: true, + }, + "outbound_policy": schema.StringAttribute{ + Description: "The default behavior for outbound traffic. This setting can be overridden by updating " + + "the outbound.action property for an individual Firewall Rule.", + Computed: true, + }, + }, +} + +var frameworkDatasourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The data source's unique ID.", + Computed: true, + }, + }, + Blocks: map[string]schema.Block{ + "filter": filterConfig.Schema(), + "firewall_templates": schema.ListNestedBlock{ + Description: "The returned list of firewall templates.", + NestedObject: templatesObject, + }, + }, +} diff --git a/linode/firewalltemplates/tmpl/data_basic.gotf b/linode/firewalltemplates/tmpl/data_basic.gotf new file mode 100644 index 000000000..97f7642d0 --- /dev/null +++ b/linode/firewalltemplates/tmpl/data_basic.gotf @@ -0,0 +1,6 @@ +{{ define "data_linode_firewall_templates_basic" }} + +data "linode_firewall_templates" "test" { +} + +{{ end }} diff --git a/linode/firewalltemplates/tmpl/data_filter.gotf b/linode/firewalltemplates/tmpl/data_filter.gotf new file mode 100644 index 000000000..03501d822 --- /dev/null +++ b/linode/firewalltemplates/tmpl/data_filter.gotf @@ -0,0 +1,10 @@ +{{ define "data_linode_firewall_templates_filter" }} + +data "linode_firewall_templates" "test" { + filter { + name = "slug" + values = ["{{ .Slug }}"] + } +} + +{{ end }} diff --git a/linode/firewalltemplates/tmpl/template.go b/linode/firewalltemplates/tmpl/template.go new file mode 100644 index 000000000..6cf626c32 --- /dev/null +++ b/linode/firewalltemplates/tmpl/template.go @@ -0,0 +1,24 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + Slug string +} + +func DataBasic(t testing.TB) string { + return acceptance.ExecuteTemplate(t, + "data_linode_firewall_templates_basic", TemplateData{}) +} + +func DataFilter(t testing.TB, slug string) string { + return acceptance.ExecuteTemplate(t, + "data_linode_firewall_templates_filter", TemplateData{ + Slug: slug, + }, + ) +} diff --git a/linode/framework_provider.go b/linode/framework_provider.go index 972aaa23c..142f7411e 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -32,6 +32,9 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/firewall" "github.com/linode/terraform-provider-linode/v3/linode/firewalldevice" "github.com/linode/terraform-provider-linode/v3/linode/firewalls" + "github.com/linode/terraform-provider-linode/v3/linode/firewallsettings" + "github.com/linode/terraform-provider-linode/v3/linode/firewalltemplate" + "github.com/linode/terraform-provider-linode/v3/linode/firewalltemplates" "github.com/linode/terraform-provider-linode/v3/linode/helper" "github.com/linode/terraform-provider-linode/v3/linode/image" "github.com/linode/terraform-provider-linode/v3/linode/images" @@ -46,6 +49,7 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/ipv6ranges" "github.com/linode/terraform-provider-linode/v3/linode/kernel" "github.com/linode/terraform-provider-linode/v3/linode/kernels" + "github.com/linode/terraform-provider-linode/v3/linode/linodeinterface" "github.com/linode/terraform-provider-linode/v3/linode/lke" "github.com/linode/terraform-provider-linode/v3/linode/lkeclusters" "github.com/linode/terraform-provider-linode/v3/linode/lkenodepool" @@ -252,6 +256,8 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res networkingipassignment.NewResource, obj.NewResource, databasemysqlv2.NewResource, + firewallsettings.NewResource, + linodeinterface.NewResource, } } @@ -332,5 +338,8 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource objendpoints.NewDataSource, objquota.NewDataSource, objquotas.NewDataSource, + firewalltemplate.NewDataSource, + firewalltemplates.NewDataSource, + firewallsettings.NewDataSource, } } diff --git a/linode/helper/conversion.go b/linode/helper/conversion.go index d73159a42..aba1d5437 100644 --- a/linode/helper/conversion.go +++ b/linode/helper/conversion.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/linode/linodego" ) @@ -110,6 +111,38 @@ func FrameworkSafeInt64PointerToIntPointer(number *int64, diags *diag.Diagnostic return linodego.Pointer(result) } +func FrameworkSafeInt64ValueToIntDoublePointerWithUnknownToNil(v types.Int64, diags *diag.Diagnostics) **int { + if v.IsUnknown() { + return linodego.DoublePointerNull[int]() + } + + return linodego.Pointer(FrameworkSafeInt64PointerToIntPointer(v.ValueInt64Pointer(), diags)) +} + +func FrameworkSafeInt64ValueToIntPointerWithUnknownToNil(v types.Int64, diags *diag.Diagnostics) *int { + if v.IsUnknown() { + return nil + } + + return linodego.Pointer(FrameworkSafeInt64ToInt(v.ValueInt64(), diags)) +} + +func ValueBoolPointerWithUnknownToNil(v types.Bool) *bool { + if v.IsUnknown() { + return nil + } + + return v.ValueBoolPointer() +} + +func ValueStringPointerWithUnknownToNil(v types.String) *string { + if v.IsUnknown() { + return nil + } + + return v.ValueStringPointer() +} + func FrameworkSafeFloat64ToInt(number float64, diags *diag.Diagnostics) int { result, err := SafeFloat64ToInt(number) if err != nil { @@ -121,6 +154,14 @@ func FrameworkSafeFloat64ToInt(number float64, diags *diag.Diagnostics) int { return result } +func IntPtrToInt64Ptr(ptr *int) *int64 { + if ptr == nil { + return nil + } + val := int64(*ptr) + return &val +} + func SafeInt64ToInt(number int64) (int, error) { if number > math.MaxInt || number < math.MinInt { return 0, fmt.Errorf("int64 value %v is out of range for int", number) diff --git a/linode/helper/framework_data.go b/linode/helper/framework_data.go index 0dfd5bfdc..cb89a616b 100644 --- a/linode/helper/framework_data.go +++ b/linode/helper/framework_data.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) func KeepOrUpdateString(original types.String, updated string, preserveKnown bool) types.String { @@ -108,9 +109,136 @@ func KeepOrUpdateBoolPointer(original types.Bool, updated *bool, preserveKnown b return KeepOrUpdateValue(original, types.BoolPointerValue(updated), preserveKnown) } +// KeepOrUpdateValue is a generic function to keep the original value if it is known when preserveKnown is true, +// or update it otherwise func KeepOrUpdateValue[T attr.Value](original T, updated T, preserveKnown bool) T { if preserveKnown && !original.IsUnknown() { return original } return updated } + +// KeepOrUpdateSingleNestedAttributes is a convenience wrapper to keep or update a single nested attribute. +// Should only use for the single nested object at root level. For multi-layer nested object, use +// KeepOrUpdateSingleNestedAttributesWithTypes instead. +func KeepOrUpdateSingleNestedAttributes[T any]( + ctx context.Context, + original types.Object, + preserveKnown bool, + diags *diag.Diagnostics, + flatten func(*T, *bool, bool, *diag.Diagnostics), +) *types.Object { + return KeepOrUpdateSingleNestedAttributesWithTypes( + ctx, original, original.AttributeTypes(ctx), preserveKnown, diags, flatten, + ) +} + +// FlattenNestedObjectFunc flattens linodego structs into their corresponding Terraform framework model structs. +// +// Set `isNull` to true if the nested object should be nullified. +// +// For any collection attribute (set, list, map) with a null value, override it with a null value with the +// corresponding element type (e.g., types.SetNull(types.StringType)). This ensures the framework can determine +// the element type when setting the attribute in the state. This is necessary because when the original nested +// object is null or unknown, the KeepOrUpdateSingleNestedAttributesWithTypes function cannot provide element +// type information for the attributes within. +type FlattenNestedObjectFunc[T any] func(model *T, isNull *bool, preserveKnown bool, diags *diag.Diagnostics) + +// This function is necessary when explicit attributes are needed for flatten the `original` +// nested object. +// +// In some cases `original` won't contain the type of its attributes. For example, a +// double nested object (nested object in another nested object) in a model; when the +// parent nested object is null or unknown, `object.As` won't put the attributes into +// the child nested object. Passing explicit attributeTypes will then be necessary. +// +// Checkout the corresponding unit tests for more details. +func KeepOrUpdateSingleNestedAttributesWithTypes[T any]( + ctx context.Context, + original types.Object, + attributeTypes map[string]attr.Type, + preserveKnown bool, + diags *diag.Diagnostics, + flatten FlattenNestedObjectFunc[T], +) *types.Object { + if preserveKnown && original.IsNull() { + return &original + } + + var attrModel T + + if !original.IsUnknown() && !original.IsNull() { + diags.Append( + original.As(ctx, &attrModel, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: false, + UnhandledUnknownAsEmpty: false, + })..., + ) + if diags.HasError() { + return nil + } + } + + preserveKnown = preserveKnown && !original.IsUnknown() + isNull := false + + flatten(&attrModel, &isNull, preserveKnown, diags) + + var updated types.Object + + // Only setting it to null when not preserving known. + // When known values are preserved, it's the flatten function's + // responsibility to handle the values of the nested attributes + if isNull && !preserveKnown { + updated = types.ObjectNull(attributeTypes) + } else { + var newDiags diag.Diagnostics + updated, newDiags = types.ObjectValueFrom(ctx, attributeTypes, attrModel) + diags.Append(newDiags...) + if diags.HasError() { + return nil + } + } + + return &updated +} + +func KeepOrUpdateSetNestedAttributeWithTypes[T any]( + ctx context.Context, + original types.Set, + elementType attr.Type, + preserveKnown bool, + diags *diag.Diagnostics, + flatten func([]types.Object, *bool, bool, *diag.Diagnostics) []T, +) *types.Set { + if preserveKnown && original.IsNull() { + return &original + } + + elements := make([]types.Object, 0, len(original.Elements())) + + if !original.IsUnknown() && !original.IsNull() { + diags.Append(original.ElementsAs(ctx, &elements, false)...) + if diags.HasError() { + return nil + } + } + + preserveKnown = preserveKnown && !original.IsUnknown() + isNull := false + + flattened := flatten(elements, &isNull, preserveKnown, diags) + + var updated types.Set + if isNull && !preserveKnown { + updated = types.SetNull(elementType) + } else { + var newDiags diag.Diagnostics + updated, newDiags = types.SetValueFrom(ctx, elementType, flattened) + diags.Append(newDiags...) + if diags.HasError() { + return nil + } + } + return &updated +} diff --git a/linode/helper/framework_data_test.go b/linode/helper/framework_data_test.go new file mode 100644 index 000000000..335b82eae --- /dev/null +++ b/linode/helper/framework_data_test.go @@ -0,0 +1,444 @@ +package helper_test + +import ( + "context" + //"fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/terraform-provider-linode/v3/linode/helper" + "github.com/stretchr/testify/assert" +) + +// Testing TF schema for generating object type + +var testNestedAttrsSchema = schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "another_number": schema.Int64Attribute{}, + }, +} + +var testSchema = schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "some_number": schema.Int64Attribute{}, + "some_string": schema.StringAttribute{}, + "some_nullable_string": schema.StringAttribute{}, + "some_bool": schema.BoolAttribute{}, + "some_nested_attrs_object": testNestedAttrsSchema, + }, +} + +// Testing TF models + +type DataUnitTestNestedAttrsModel struct { + AnotherNumber types.Int64 `tfsdk:"another_number"` +} + +type DataUnitTestAttrsModel struct { + SomeNumber types.Int64 `tfsdk:"some_number"` + SomeString types.String `tfsdk:"some_string"` + SomeNullableString types.String `tfsdk:"some_nullable_string"` + SomeBool types.Bool `tfsdk:"some_bool"` + SomeNestedAttrsObject types.Object `tfsdk:"some_nested_attrs_object"` +} + +// Testing structs, mocking usual linodego structs + +type DataUnitTestExpandedNestedObject struct { + AnotherNumber int +} + +type DataUnitTestExpandedObject struct { + SomeNumber int + SomeString string + SomeNullableString *string + SomeBool bool + SomeNestedObject DataUnitTestExpandedNestedObject +} + +func TestKeepOrUpdateSingleNestedAttribute(t *testing.T) { + // This test covers both the general behavior of KeepOrUpdateSingleNestedAttributes + // and the specific isNull boolean parameter behavior. The isNull parameter allows + // the flatten function to indicate that the target should be a Terraform null value. + ctx := context.Background() + objectType := testSchema.GetType().(types.ObjectType).AttrTypes + nestedObjectType := testNestedAttrsSchema.GetType().(types.ObjectType).AttrTypes + + expanded := DataUnitTestExpandedObject{ + SomeNumber: 123, + SomeString: "Hello, world!", + SomeNullableString: nil, + SomeBool: true, + SomeNestedObject: DataUnitTestExpandedNestedObject{ + AnotherNumber: 1234, + }, + } + + expectedWhenOverrideAll := types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(int64(expanded.SomeNumber)), + "some_string": types.StringValue(expanded.SomeString), + "some_nullable_string": types.StringPointerValue(expanded.SomeNullableString), + "some_bool": types.BoolValue(expanded.SomeBool), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Value(int64(expanded.SomeNestedObject.AnotherNumber)), + }, + ), + }, + ) + + tests := map[string]struct { + input types.Object + data DataUnitTestExpandedObject + expected types.Object + preserveKnown bool + setIsNull bool + }{ + "unknown object with preserving known": { + input: types.ObjectUnknown(objectType), + data: expanded, + expected: expectedWhenOverrideAll, + preserveKnown: true, + setIsNull: false, + }, + "null object with preserving known": { + input: types.ObjectNull(objectType), + data: expanded, + expected: types.ObjectNull(objectType), + preserveKnown: true, + setIsNull: false, + }, + "partially known object with preserving known": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringValue("Hey there!"), + "some_nullable_string": types.StringUnknown(), + "some_bool": types.BoolUnknown(), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Unknown(), + }, + ), + }, + ), + data: expanded, + expected: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringValue("Hey there!"), + "some_nullable_string": types.StringPointerValue(expanded.SomeNullableString), + "some_bool": types.BoolValue(expanded.SomeBool), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Value(int64(expanded.SomeNestedObject.AnotherNumber)), + }, + ), + }, + ), + preserveKnown: true, + setIsNull: false, + }, + "null nested object with preserving known": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringUnknown(), + "some_nullable_string": types.StringUnknown(), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectNull(nestedObjectType), + }, + ), + data: expanded, + expected: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringValue(expanded.SomeString), + "some_nullable_string": types.StringPointerValue(expanded.SomeNullableString), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectNull(nestedObjectType), + }, + ), + preserveKnown: true, + setIsNull: false, + }, + "unknown nested object with preserving known": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringUnknown(), + "some_nullable_string": types.StringUnknown(), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectUnknown(nestedObjectType), + }, + ), + data: expanded, + expected: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringValue(expanded.SomeString), + "some_nullable_string": types.StringPointerValue(expanded.SomeNullableString), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Value(int64(expanded.SomeNestedObject.AnotherNumber)), + }, + ), + }, + ), + preserveKnown: true, + setIsNull: false, + }, + "all known with preserving known": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringValue("Hey there!"), + "some_nullable_string": types.StringValue("I'm here!"), + "some_bool": types.BoolValue(true), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Value(321), + }, + ), + }, + ), + data: expanded, + expected: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringValue("Hey there!"), + "some_nullable_string": types.StringValue("I'm here!"), + "some_bool": types.BoolValue(true), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Value(321), + }, + ), + }, + ), + preserveKnown: true, + setIsNull: false, + }, + + "unknown object without preserving known": { + input: types.ObjectUnknown(objectType), + data: expanded, + expected: expectedWhenOverrideAll, + preserveKnown: false, + setIsNull: false, + }, + "null object without preserving known": { + input: types.ObjectNull(objectType), + data: expanded, + expected: expectedWhenOverrideAll, + preserveKnown: false, + setIsNull: false, + }, + "partially known object without preserving known": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringUnknown(), + "some_nullable_string": types.StringUnknown(), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Unknown(), + }, + ), + }, + ), + data: expanded, + expected: expectedWhenOverrideAll, + preserveKnown: false, + setIsNull: false, + }, + "null nested object without preserving known": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringUnknown(), + "some_nullable_string": types.StringUnknown(), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectNull(nestedObjectType), + }, + ), + data: expanded, + expected: expectedWhenOverrideAll, + preserveKnown: false, + setIsNull: false, + }, + "unknown nested object without preserving known": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringUnknown(), + "some_nullable_string": types.StringUnknown(), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectUnknown(nestedObjectType), + }, + ), + data: expanded, + expected: expectedWhenOverrideAll, + preserveKnown: false, + setIsNull: false, + }, + "all known without preserving known": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(12345), + "some_string": types.StringValue("Hey there!"), + "some_nullable_string": types.StringNull(), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Null(), + }, + ), + }, + ), + data: expanded, + expected: expectedWhenOverrideAll, + preserveKnown: false, + setIsNull: false, + }, + + // isNull behavior tests + "isNull=true without preserving known should return null object": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(999), + "some_string": types.StringValue("Existing value"), + "some_nullable_string": types.StringValue("Keep me"), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Value(888), + }, + ), + }, + ), + data: expanded, + expected: types.ObjectNull(objectType), + preserveKnown: false, + setIsNull: true, + }, + "isNull=true with preserving known should ignore isNull and preserve original": { + input: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(999), + "some_string": types.StringValue("Existing value"), + "some_nullable_string": types.StringValue("Keep me"), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Value(888), + }, + ), + }, + ), + data: expanded, + expected: types.ObjectValueMust( + objectType, + map[string]attr.Value{ + "some_number": types.Int64Value(999), + "some_string": types.StringValue("Existing value"), + "some_nullable_string": types.StringValue("Keep me"), + "some_bool": types.BoolValue(false), + "some_nested_attrs_object": types.ObjectValueMust( + nestedObjectType, + map[string]attr.Value{ + "another_number": types.Int64Value(888), + }, + ), + }, + ), + preserveKnown: true, + setIsNull: true, + }, + "isNull=true with preserving known on null input should return null": { + input: types.ObjectNull(objectType), + data: expanded, + expected: types.ObjectNull(objectType), + preserveKnown: true, + setIsNull: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var diags diag.Diagnostics + + flatten := func(model *DataUnitTestAttrsModel, isNull *bool, preserveKnown bool, diags *diag.Diagnostics) { + *isNull = tt.setIsNull + + if !*isNull { + // Only populate the model if we're not setting it to null + model.SomeNumber = helper.KeepOrUpdateInt64(model.SomeNumber, int64(tt.data.SomeNumber), preserveKnown) + model.SomeString = helper.KeepOrUpdateString(model.SomeString, tt.data.SomeString, preserveKnown) + model.SomeNullableString = helper.KeepOrUpdateStringPointer(model.SomeNullableString, tt.data.SomeNullableString, preserveKnown) + model.SomeBool = helper.KeepOrUpdateBool(model.SomeBool, tt.data.SomeBool, preserveKnown) + + flattenNestedObject := helper.KeepOrUpdateSingleNestedAttributesWithTypes( + ctx, + model.SomeNestedAttrsObject, + nestedObjectType, + preserveKnown, + diags, + func(nestedModel *DataUnitTestNestedAttrsModel, _ *bool, preserveKnown bool, _ *diag.Diagnostics) { + nestedModel.AnotherNumber = helper.KeepOrUpdateInt64( + nestedModel.AnotherNumber, + int64(tt.data.SomeNestedObject.AnotherNumber), + preserveKnown, + ) + }, + ) + + if diags.HasError() { + return + } + + model.SomeNestedAttrsObject = *flattenNestedObject + } + } + + out := helper.KeepOrUpdateSingleNestedAttributes(ctx, tt.input, tt.preserveKnown, &diags, flatten) + if diags.HasError() { + t.Fatalf("unexpected error: %v", diags) + } + + assert.Truef(t, out.Equal(tt.expected), + "Flattened object (%v) should match expected object (%v)", out, tt.expected) + }) + } +} diff --git a/linode/helper/setplanmodifiers/usestateforunknownif.go b/linode/helper/setplanmodifiers/usestateforunknownif.go index 5fee23cb6..f565b6680 100644 --- a/linode/helper/setplanmodifiers/usestateforunknownif.go +++ b/linode/helper/setplanmodifiers/usestateforunknownif.go @@ -3,31 +3,97 @@ package setplanmodifiers import ( "context" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" ) +// UseStateForUnknownUnlessTheseChanged is a convenience wrapper to only use the state value +// in place of unknown values in plans unless another attribute has been changed. +func UseStateForUnknownUnlessTheseChanged(expressions ...path.Expression) planmodifier.Set { + return UseStateForUnknownIf( + func(ctx context.Context, request planmodifier.SetRequest, resp *UseStateForUnknownIfFuncResponse) { + if len(expressions) == 0 { + resp.UseState = true + return + } + + expressions := request.PathExpression.MergeExpressions(expressions...) + + for _, expression := range expressions { + matchedPaths, newDiags := request.Config.PathMatches(ctx, expression) + + resp.Diagnostics.Append(newDiags...) + if resp.Diagnostics.HasError() { + return + } + + for _, mp := range matchedPaths { + var state, plan attr.Value + + newDiags = request.Plan.GetAttribute(ctx, mp, &plan) + + resp.Diagnostics.Append(newDiags...) + if resp.Diagnostics.HasError() { + return + } + + if plan.IsUnknown() { + continue + } + + newDiags = request.State.GetAttribute(ctx, mp, &state) + + resp.Diagnostics.Append(newDiags...) + if resp.Diagnostics.HasError() { + return + } + + if !state.Equal(plan) { + // panic(fmt.Sprintf("state and config should not be equal here: state=%#v config=%#v, state_is_null=%v, config_is_null=%v", state, config, state.IsNull(), config.IsNull())) + resp.UseState = false + return + } + } + } + + resp.UseState = true + }, + ) +} + // UseStateForUnknownIfNotNull is a convenience wrapper to only use the state value // in place of unknown values in plans if its value is not null. func UseStateForUnknownIfNotNull() planmodifier.Set { return UseStateForUnknownIf( - func(ctx context.Context, request planmodifier.SetRequest) bool { - return !request.StateValue.IsNull() + func(ctx context.Context, request planmodifier.SetRequest, resp *UseStateForUnknownIfFuncResponse) { + resp.UseState = !request.StateValue.IsNull() }, ) } -type planModifierCondition func(context.Context, planmodifier.SetRequest) bool +type UseStateForUnknownIfFunc func(context.Context, planmodifier.SetRequest, *UseStateForUnknownIfFuncResponse) + +type UseStateForUnknownIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // UseState should be enabled if conditions are met + UseState bool +} // UseStateForUnknownIf returns a plan modifier that will only use the state value // in place of an unknown value if the given condition is met. -func UseStateForUnknownIf(condition planModifierCondition) planmodifier.Set { +func UseStateForUnknownIf(condition UseStateForUnknownIfFunc) planmodifier.Set { return useStateForUnknownIfModifier{ conditionFunc: condition, } } type useStateForUnknownIfModifier struct { - conditionFunc planModifierCondition + conditionFunc UseStateForUnknownIfFunc } func (m useStateForUnknownIfModifier) Description(_ context.Context) string { @@ -60,7 +126,9 @@ func (m useStateForUnknownIfModifier) PlanModifySet( return } - if !m.conditionFunc(ctx, req) { + ifFuncResp := &UseStateForUnknownIfFuncResponse{} + + if m.conditionFunc(ctx, req, ifFuncResp); !ifFuncResp.UseState { return } diff --git a/linode/helper/setplanmodifiers/usestateforunknownif_test.go b/linode/helper/setplanmodifiers/usestateforunknownif_test.go index 605f3855f..a4ceb1434 100644 --- a/linode/helper/setplanmodifiers/usestateforunknownif_test.go +++ b/linode/helper/setplanmodifiers/usestateforunknownif_test.go @@ -113,7 +113,7 @@ func TestUseStateForUnknownIfNotNull(t *testing.T) { func TestUseStateForUnknownIf(t *testing.T) { testCases := map[string]struct { request planmodifier.SetRequest - condition func(context.Context, planmodifier.SetRequest) bool + condition setplanmodifiers.UseStateForUnknownIfFunc expected *planmodifier.SetResponse }{ "condition-false": { @@ -126,8 +126,8 @@ func TestUseStateForUnknownIf(t *testing.T) { PlanValue: types.SetUnknown(types.StringType), ConfigValue: types.SetNull(types.StringType), }, - condition: func(ctx context.Context, req planmodifier.SetRequest) bool { - return false + condition: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifiers.UseStateForUnknownIfFuncResponse) { + resp.UseState = false }, expected: &planmodifier.SetResponse{ PlanValue: types.SetUnknown(types.StringType), @@ -143,8 +143,8 @@ func TestUseStateForUnknownIf(t *testing.T) { PlanValue: types.SetUnknown(types.StringType), ConfigValue: types.SetNull(types.StringType), }, - condition: func(ctx context.Context, req planmodifier.SetRequest) bool { - return true + condition: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifiers.UseStateForUnknownIfFuncResponse) { + resp.UseState = true }, expected: &planmodifier.SetResponse{ PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("state")}), @@ -160,9 +160,9 @@ func TestUseStateForUnknownIf(t *testing.T) { PlanValue: types.SetUnknown(types.StringType), ConfigValue: types.SetNull(types.StringType), }, - condition: func(ctx context.Context, req planmodifier.SetRequest) bool { + condition: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifiers.UseStateForUnknownIfFuncResponse) { elements := req.StateValue.Elements() - return !req.StateValue.IsNull() && len(elements) > 0 + resp.UseState = !req.StateValue.IsNull() && len(elements) > 0 }, expected: &planmodifier.SetResponse{ PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("item")}), @@ -178,9 +178,9 @@ func TestUseStateForUnknownIf(t *testing.T) { PlanValue: types.SetUnknown(types.StringType), ConfigValue: types.SetNull(types.StringType), }, - condition: func(ctx context.Context, req planmodifier.SetRequest) bool { + condition: func(ctx context.Context, req planmodifier.SetRequest, resp *setplanmodifiers.UseStateForUnknownIfFuncResponse) { elements := req.StateValue.Elements() - return !req.StateValue.IsNull() && len(elements) > 0 + resp.UseState = !req.StateValue.IsNull() && len(elements) > 0 }, expected: &planmodifier.SetResponse{ PlanValue: types.SetUnknown(types.StringType), diff --git a/linode/instance/datasource.go b/linode/instance/datasource.go index 372771dcd..1a7b3d792 100644 --- a/linode/instance/datasource.go +++ b/linode/instance/datasource.go @@ -19,11 +19,12 @@ var filterConfig = helper.FilterConfig{ "lke_cluster_id": {APIFilterable: true, TypeFunc: helper.FilterTypeInt}, // Tags must be filtered on the client - "tags": {TypeFunc: helper.FilterTypeString}, - "status": {TypeFunc: helper.FilterTypeString}, - "type": {TypeFunc: helper.FilterTypeString}, - "watchdog_enabled": {TypeFunc: helper.FilterTypeBool}, - "disk_encryption": {TypeFunc: helper.FilterTypeString}, + "tags": {TypeFunc: helper.FilterTypeString}, + "status": {TypeFunc: helper.FilterTypeString}, + "type": {TypeFunc: helper.FilterTypeString}, + "watchdog_enabled": {TypeFunc: helper.FilterTypeBool}, + "disk_encryption": {TypeFunc: helper.FilterTypeString}, + "interface_generation": {TypeFunc: helper.FilterTypeString}, } func dataSourceInstance() *schema.Resource { diff --git a/linode/instance/datasource_test.go b/linode/instance/datasource_test.go index 4629934ab..3d47573ae 100644 --- a/linode/instance/datasource_test.go +++ b/linode/instance/datasource_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/acceptance" "github.com/linode/terraform-provider-linode/v3/linode/instance/tmpl" ) @@ -181,6 +182,66 @@ func TestAccDataSourceInstances_multipleInstances(t *testing.T) { }) } +func TestAccDataSourceInstances_explicitInterfaceGeneration(t *testing.T) { + t.Parallel() + + resName := "data.linode_instances.foobar" + instanceName := acctest.RandomWithPrefix("tf_test") + + firstInstancePath := tfjsonpath.New("instances").AtSliceIndex(0) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: acceptance.CheckInstanceDestroy, + + Steps: []resource.TestStep{ + { + Config: tmpl.DataExplicitInterfaceGeneration( + t, + instanceName, + testRegion, + acceptance.TestImageLatest, + linodego.GenerationLinode, + false, + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("instances"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + resName, + firstInstancePath.AtMapKey("label"), + knownvalue.StringExact(instanceName), + ), + statecheck.ExpectKnownValue( + resName, + firstInstancePath.AtMapKey("type"), + knownvalue.StringExact("g6-nanode-1"), + ), + statecheck.ExpectKnownValue( + resName, + firstInstancePath.AtMapKey("image"), + knownvalue.StringExact(acceptance.TestImageLatest), + ), + statecheck.ExpectKnownValue( + resName, + firstInstancePath.AtMapKey("region"), + knownvalue.StringExact(testRegion), + ), + statecheck.ExpectKnownValue( + resName, + firstInstancePath.AtMapKey("interface_generation"), + knownvalue.StringExact(string(linodego.GenerationLinode)), + ), + }, + }, + }, + }) +} + func TestAccDataSourceInstance_interfaceVPCIPv6(t *testing.T) { t.Parallel() diff --git a/linode/instance/flatten.go b/linode/instance/flatten.go index 466455693..891fb8d47 100644 --- a/linode/instance/flatten.go +++ b/linode/instance/flatten.go @@ -49,6 +49,7 @@ func flattenInstance( result["tags"] = instance.Tags result["capabilities"] = instance.Capabilities result["image"] = instance.Image + result["interface_generation"] = instance.InterfaceGeneration result["host_uuid"] = instance.HostUUID result["has_user_data"] = instance.HasUserData result["disk_encryption"] = instance.DiskEncryption @@ -222,6 +223,7 @@ func flattenInstanceSimple(instance *linodego.Instance) (map[string]interface{}, result["tags"] = instance.Tags result["capabilities"] = instance.Capabilities result["image"] = instance.Image + result["interface_generation"] = instance.InterfaceGeneration result["host_uuid"] = instance.HostUUID result["backups"] = flattenInstanceBackups(*instance) result["specs"] = flattenInstanceSpecs(*instance) diff --git a/linode/instance/resource.go b/linode/instance/resource.go index 2d45f0662..e110a6f7a 100644 --- a/linode/instance/resource.go +++ b/linode/instance/resource.go @@ -135,6 +135,7 @@ func readResource(ctx context.Context, d *schema.ResourceData, meta interface{}) d.Set("has_user_data", instance.HasUserData) d.Set("lke_cluster_id", instance.LKEClusterID) d.Set("disk_encryption", instance.DiskEncryption) + d.Set("interface_generation", instance.InterfaceGeneration) flatSpecs := flattenInstanceSpecs(*instance) flatAlerts := flattenInstanceAlerts(*instance) @@ -240,6 +241,14 @@ func createResource(ctx context.Context, d *schema.ResourceData, meta interface{ } } + if interfaceGeneration, interfaceGenerationOk := d.GetOk("interface_generation"); interfaceGenerationOk { + createOpts.InterfaceGeneration = linodego.InterfaceGeneration(interfaceGeneration.(string)) + } + + if networkHelper, networkHelperOk := d.GetOk("network_helper"); networkHelperOk { + createOpts.NetworkHelper = linodego.Pointer(networkHelper.(bool)) + } + if _, metadataOk := d.GetOk("metadata.0"); metadataOk { var metadata linodego.InstanceMetadataOptions diff --git a/linode/instance/resource_test.go b/linode/instance/resource_test.go index 41fef0948..713c28f99 100644 --- a/linode/instance/resource_test.go +++ b/linode/instance/resource_test.go @@ -2709,6 +2709,136 @@ func TestAccResourceInstance_diskEncryption(t *testing.T) { }) } +func TestAccResourceInstance_interfaceGenerationLegacy(t *testing.T) { + t.Parallel() + + resName := "linode_instance.foobar" + instanceName := acctest.RandomWithPrefix("tf_test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: acceptance.CheckInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.ExplicitInterfaceGeneration( + t, + instanceName, + testRegion, + true, + linodego.GenerationLegacyConfig, + nil, + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("label"), + knownvalue.StringExact(instanceName), + ), + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("type"), + knownvalue.StringExact("g6-nanode-1"), + ), + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("image"), + knownvalue.StringExact(acceptance.TestImageLatest), + ), + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("region"), + knownvalue.StringExact(testRegion), + ), + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("interface_generation"), + knownvalue.StringExact(string(linodego.GenerationLegacyConfig)), + ), + }, + }, + + { + ResourceName: resName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"root_pass", "authorized_keys", "image", "resize_disk", "migration_type", "firewall_id"}, + }, + }, + }) +} + +func TestAccResourceInstance_interfaceGenerationLinode(t *testing.T) { + t.Parallel() + + resName := "linode_instance.foobar" + instanceName := acctest.RandomWithPrefix("tf_test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: acceptance.CheckInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.ExplicitInterfaceGeneration( + t, + instanceName, + testRegion, + true, + linodego.GenerationLinode, + linodego.Pointer(true), + ), + ExpectError: regexp.MustCompile( + "The Linode must have at least 1 interface defined to boot", + ), + }, + { + Config: tmpl.ExplicitInterfaceGeneration( + t, + instanceName, + testRegion, + false, + linodego.GenerationLinode, + linodego.Pointer(true), + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("label"), + knownvalue.StringExact(instanceName), + ), + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("type"), + knownvalue.StringExact("g6-nanode-1"), + ), + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("image"), + knownvalue.StringExact(acceptance.TestImageLatest), + ), + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("region"), + knownvalue.StringExact(testRegion), + ), + statecheck.ExpectKnownValue( + resName, + tfjsonpath.New("interface_generation"), + knownvalue.StringExact(string(linodego.GenerationLinode)), + ), + }, + }, + { + ResourceName: resName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"root_pass", "authorized_keys", "image", "resize_disk", "migration_type", "firewall_id", "network_helper"}, + }, + }, + }) +} + func TestAccResourceInstance_interfaceVPCIPv6(t *testing.T) { t.Parallel() diff --git a/linode/instance/schema_datasource.go b/linode/instance/schema_datasource.go index eacb1a86c..c1ca4f0f3 100644 --- a/linode/instance/schema_datasource.go +++ b/linode/instance/schema_datasource.go @@ -65,6 +65,12 @@ var instanceDataSourceSchema = map[string]*schema.Schema{ Description: "The status of the instance, indicating the current readiness state.", Computed: true, }, + "interface_generation": { + Type: schema.TypeString, + Description: "The interface type for the Linode. " + + "NOTE: Linode Interfaces may not currently be available to all users.", + Computed: true, + }, "ip_address": { Type: schema.TypeString, Description: "This Linode's Public IPv4 Address. If there are multiple public IPv4 addresses on this " + diff --git a/linode/instance/schema_resource.go b/linode/instance/schema_resource.go index 20c4612ed..d36d394e5 100644 --- a/linode/instance/schema_resource.go +++ b/linode/instance/schema_resource.go @@ -538,6 +538,12 @@ var resourceSchema = map[string]*schema.Schema{ Description: "Various fields related to the Linode Metadata service.", Optional: true, }, + "network_helper": { + Type: schema.TypeBool, + Description: "Whether Network Helper should be enabled for this instance.", + Optional: true, + ForceNew: true, + }, "placement_group": { Type: schema.TypeList, Elem: resourcePlacementGroup(), @@ -558,6 +564,19 @@ var resourceSchema = map[string]*schema.Schema{ return true }, }, + "interface_generation": { + Type: schema.TypeString, + Description: "Specifies the interface type for the Linode. " + + "The default value is determined by the interfaces_for_new_linodes " + + "setting in the account settings. " + + "If the interface_generation option is set to linode, " + + "legacy configuration interfaces can no longer be used on the Linode. " + + "NOTE: Linode Interfaces may not currently be available to all users.", + Optional: true, + Computed: true, + ForceNew: true, + }, + "has_user_data": { Type: schema.TypeBool, Description: "Whether or not this Instance was created with user-data.", diff --git a/linode/instance/tmpl/template.go b/linode/instance/tmpl/template.go index 7a0974210..48258e701 100644 --- a/linode/instance/tmpl/template.go +++ b/linode/instance/tmpl/template.go @@ -30,7 +30,9 @@ type TemplateData struct { DiskEncryption *linodego.InstanceDiskEncryption - MaintenancePolicy string + InterfaceGeneration linodego.InterfaceGeneration + NetworkHelper *bool + MaintenancePolicy string } func Basic(t testing.TB, label, pubKey, region string, rootPass string) string { @@ -168,6 +170,25 @@ func InterfacesUpdateEmpty(t testing.TB, label, region string) string { }) } +func ExplicitInterfaceGeneration( + t testing.TB, + label, + region string, + booted bool, + generation linodego.InterfaceGeneration, + networkHelper *bool, +) string { + return acceptance.ExecuteTemplate(t, + "instance_explicit_interface_generation", TemplateData{ + Label: label, + Image: acceptance.TestImageLatest, + Region: region, + InterfaceGeneration: generation, + NetworkHelper: networkHelper, + Booted: booted, + }) +} + func ConfigInterfaces(t testing.TB, label, region string, rootPass string) string { return acceptance.ExecuteTemplate(t, "instance_config_interfaces", TemplateData{ @@ -713,6 +734,24 @@ func DataClientFilter(t testing.TB, label, tag, region string, rootPass string) }) } +func DataExplicitInterfaceGeneration( + t testing.TB, + label, + region, + image string, + generation linodego.InterfaceGeneration, + booted bool, +) string { + return acceptance.ExecuteTemplate(t, + "instance_data_explicit_interface_generation", TemplateData{ + Label: label, + Region: region, + Image: image, + InterfaceGeneration: generation, + Booted: booted, + }) +} + func FirewallOnCreation(t testing.TB, label, region string, rootPass string) string { return acceptance.ExecuteTemplate(t, "instance_firewall_on_creation", TemplateData{ diff --git a/linode/instance/tmpl/templates/data_explicit_interface_generation.gotf b/linode/instance/tmpl/templates/data_explicit_interface_generation.gotf new file mode 100644 index 000000000..6d05e9db1 --- /dev/null +++ b/linode/instance/tmpl/templates/data_explicit_interface_generation.gotf @@ -0,0 +1,17 @@ +{{ define "instance_data_explicit_interface_generation" }} + +{{ template "instance_explicit_interface_generation" . }} + +data "linode_instances" "foobar" { + filter { + name = "id" + values = [linode_instance.foobar.id] + } + + filter { + name = "interface_generation" + values = ["{{ .InterfaceGeneration }}"] + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/instance/tmpl/templates/explicit_interface_generation.gotf b/linode/instance/tmpl/templates/explicit_interface_generation.gotf new file mode 100644 index 000000000..ebd6ef407 --- /dev/null +++ b/linode/instance/tmpl/templates/explicit_interface_generation.gotf @@ -0,0 +1,20 @@ +{{ define "instance_explicit_interface_generation" }} + +{{ template "e2e_test_firewall" . }} + +resource "linode_instance" "foobar" { + label = "{{ .Label }}" + type = "g6-nanode-1" + image = "{{.Image}}" + region = "{{ .Region }}" + firewall_id = linode_firewall.e2e_test_firewall.id + booted = {{ .Booted }} + + interface_generation = "{{ .InterfaceGeneration }}" + + {{ if .NetworkHelper }} + network_helper = {{ .NetworkHelper }} + {{ end }} +} + +{{ end }} \ No newline at end of file diff --git a/linode/instancenetworking/datasource_test.go b/linode/instancenetworking/datasource_test.go index 5ec48de83..fc0495a75 100644 --- a/linode/instancenetworking/datasource_test.go +++ b/linode/instancenetworking/datasource_test.go @@ -111,3 +111,5 @@ func TestAccDataSourceInstanceNetworking_basicwithReseved(t *testing.T) { }, }) } + +// TODO (Linode Interfaces): Add test for new interface_id field. diff --git a/linode/instancenetworking/framework_datasource_schema.go b/linode/instancenetworking/framework_datasource_schema.go index 0b1bf1e5a..ad3f49761 100644 --- a/linode/instancenetworking/framework_datasource_schema.go +++ b/linode/instancenetworking/framework_datasource_schema.go @@ -16,16 +16,17 @@ var VPCNAT1To1Type = types.ObjectType{ var networkObjectType = types.ObjectType{ AttrTypes: map[string]attr.Type{ - "address": types.StringType, - "gateway": types.StringType, - "prefix": types.Int64Type, - "rdns": types.StringType, - "region": types.StringType, - "subnet_mask": types.StringType, - "type": types.StringType, - "public": types.BoolType, - "linode_id": types.Int64Type, - "vpc_nat_1_1": VPCNAT1To1Type, + "address": types.StringType, + "gateway": types.StringType, + "prefix": types.Int64Type, + "rdns": types.StringType, + "region": types.StringType, + "subnet_mask": types.StringType, + "type": types.StringType, + "public": types.BoolType, + "linode_id": types.Int64Type, + "interface_id": types.Int64Type, + "vpc_nat_1_1": VPCNAT1To1Type, }, } diff --git a/linode/instancenetworking/framework_models.go b/linode/instancenetworking/framework_models.go index 5cfee4123..ec7ff392c 100644 --- a/linode/instancenetworking/framework_models.go +++ b/linode/instancenetworking/framework_models.go @@ -185,6 +185,12 @@ func flattenIP(network *linodego.InstanceIP) ( result["public"] = types.BoolValue(network.Public) result["linode_id"] = types.Int64Value(int64(network.LinodeID)) + if network.InterfaceID != nil { + result["interface_id"] = types.Int64Value(int64(*network.InterfaceID)) + } else { + result["interface_id"] = types.Int64Null() + } + obj, d := types.ObjectValue(networkObjectType.AttrTypes, result) if d.HasError() { return nil, d diff --git a/linode/linodeinterface/framework_default_route_model.go b/linode/linodeinterface/framework_default_route_model.go new file mode 100644 index 000000000..4a8d58905 --- /dev/null +++ b/linode/linodeinterface/framework_default_route_model.go @@ -0,0 +1,33 @@ +package linodeinterface + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type DefaultRouteAttrModel struct { + IPv4 types.Bool `tfsdk:"ipv4"` + IPv6 types.Bool `tfsdk:"ipv6"` +} + +func (plan *DefaultRouteAttrModel) GetCreateOrUpdateOptions(state *DefaultRouteAttrModel) (opts linodego.InterfaceDefaultRoute, shouldUpdate bool) { + if !plan.IPv4.IsUnknown() && (state == nil || !state.IPv4.Equal(plan.IPv4)) { + opts.IPv4 = plan.IPv4.ValueBoolPointer() + shouldUpdate = true + } + + if !plan.IPv6.IsUnknown() && (state == nil || !state.IPv6.Equal(plan.IPv6)) { + opts.IPv6 = plan.IPv6.ValueBoolPointer() + shouldUpdate = true + } + + return opts, shouldUpdate +} + +func (data *DefaultRouteAttrModel) FlattenInterfaceDefaultRoute( + defaultRoute linodego.InterfaceDefaultRoute, preserveKnown bool, +) { + data.IPv4 = helper.KeepOrUpdateBoolPointer(data.IPv4, defaultRoute.IPv4, preserveKnown) + data.IPv6 = helper.KeepOrUpdateBoolPointer(data.IPv6, defaultRoute.IPv6, preserveKnown) +} diff --git a/linode/linodeinterface/framework_models.go b/linode/linodeinterface/framework_models.go new file mode 100644 index 000000000..2b57be3fd --- /dev/null +++ b/linode/linodeinterface/framework_models.go @@ -0,0 +1,219 @@ +package linodeinterface + +import ( + "context" + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type LinodeInterfaceModel struct { + ID types.String `tfsdk:"id"` + LinodeID types.Int64 `tfsdk:"linode_id"` + FirewallID types.Int64 `tfsdk:"firewall_id"` + DefaultRoute types.Object `tfsdk:"default_route"` + Public types.Object `tfsdk:"public"` + VLAN types.Object `tfsdk:"vlan"` + VPC types.Object `tfsdk:"vpc"` +} + +// Structs for Public Interfaces + +// Structs for VLAN Interfaces + +// Structs for VPC Interfaces + +// type VPCAttrModel struct { +// IPv4 types.Bool `tfsdk:"ipv4"` +// IPv6 types.Bool `tfsdk:"ipv6"` +// } + +func (state *LinodeInterfaceModel) GetIDs(diags *diag.Diagnostics) (linodeID int, id int) { + id, err := strconv.Atoi(state.ID.ValueString()) + if err != nil { + diags.AddError( + "Failed to Convert ID Type", + fmt.Sprintf( + "This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Failed to convert string ID %q to an integer.\n", id, + ), + ) + } + linodeID = helper.FrameworkSafeInt64ToInt(state.LinodeID.ValueInt64(), diags) + return linodeID, id +} + +func (plan *LinodeInterfaceModel) GetCreateOptions(ctx context.Context, diags *diag.Diagnostics) (opts linodego.LinodeInterfaceCreateOptions, linodeID int) { + if !plan.DefaultRoute.IsUnknown() && !plan.DefaultRoute.IsNull() { + var planDefaultRoute DefaultRouteAttrModel + plan.DefaultRoute.As(ctx, &planDefaultRoute, basetypes.ObjectAsOptions{}) + defaultRouteOpts, _ := planDefaultRoute.GetCreateOrUpdateOptions(nil) + opts.DefaultRoute = linodego.Pointer(defaultRouteOpts) + } + + if !plan.FirewallID.IsUnknown() { + opts.FirewallID = helper.FrameworkSafeInt64ValueToIntDoublePointerWithUnknownToNil(plan.FirewallID, diags) + if diags.HasError() { + return opts, linodeID + } + } + + if !plan.Public.IsUnknown() && !plan.Public.IsNull() { + var planPublicInterface PublicAttrModel + plan.Public.As(ctx, &planPublicInterface, basetypes.ObjectAsOptions{}) + publicOpts, _ := planPublicInterface.GetCreateOrUpdateOptions(ctx, nil) + opts.Public = linodego.Pointer(publicOpts) + } else if !plan.VLAN.IsUnknown() && !plan.VLAN.IsNull() { + var planVLANInterface VLANAttrModel + plan.VLAN.As(ctx, &planVLANInterface, basetypes.ObjectAsOptions{}) + opts.VLAN = linodego.Pointer(planVLANInterface.GetCreateOptions()) + } else if !plan.VPC.IsUnknown() && !plan.VPC.IsNull() { + var planVPCInterface VPCAttrModel + plan.VPC.As(ctx, &planVPCInterface, basetypes.ObjectAsOptions{}) + vpc := planVPCInterface.GetCreateOptions(ctx, diags) + opts.VPC = linodego.Pointer(vpc) + } + + linodeID = helper.FrameworkSafeInt64ToInt(plan.LinodeID.ValueInt64(), diags) + return opts, linodeID +} + +func (plan *LinodeInterfaceModel) GetUpdateOptions( + ctx context.Context, + state LinodeInterfaceModel, + diags *diag.Diagnostics, +) (opts linodego.LinodeInterfaceUpdateOptions) { + if !plan.DefaultRoute.IsUnknown() && !plan.DefaultRoute.IsNull() { + var planDefaultRoute DefaultRouteAttrModel + var stateDefaultRoute *DefaultRouteAttrModel + plan.DefaultRoute.As(ctx, &planDefaultRoute, basetypes.ObjectAsOptions{}) + + // state can't be unknown, checking null is enough here + if !state.DefaultRoute.IsNull() { + state.DefaultRoute.As(ctx, &stateDefaultRoute, basetypes.ObjectAsOptions{}) + } + + if updatedDefaultRoute, ok := planDefaultRoute.GetCreateOrUpdateOptions(stateDefaultRoute); ok { + opts.DefaultRoute = linodego.Pointer(updatedDefaultRoute) + } + } + + if !plan.Public.IsUnknown() && !plan.Public.IsNull() { + var planPublicInterface PublicAttrModel + var statePublicInterface *PublicAttrModel + plan.Public.As(ctx, &planPublicInterface, basetypes.ObjectAsOptions{}) + + // state can't be unknown, checking null is enough here + if !state.Public.IsNull() { + state.Public.As(ctx, &statePublicInterface, basetypes.ObjectAsOptions{}) + } + + if updatedPublicInterface, shouldUpdate := planPublicInterface.GetCreateOrUpdateOptions(ctx, statePublicInterface); shouldUpdate { + opts.Public = linodego.Pointer(updatedPublicInterface) + } + } + + if !plan.VPC.IsUnknown() && !plan.VPC.IsNull() { + var planVPCInterface VPCAttrModel + var stateVPCInterface *VPCAttrModel + plan.VPC.As(ctx, &planVPCInterface, basetypes.ObjectAsOptions{}) + + // state can't be unknown, checking null is enough here + if !state.VPC.IsNull() { + state.VPC.As(ctx, &stateVPCInterface, basetypes.ObjectAsOptions{}) + } + + if updatedVPCInterface, ok := planVPCInterface.GetUpdateOptions(ctx, stateVPCInterface, diags); ok { + opts.VPC = linodego.Pointer(updatedVPCInterface) + } + } + + // VLAN interface can't be updated, so no need to check it here + + return opts +} + +func (data *LinodeInterfaceModel) FlattenInterface( + ctx context.Context, i linodego.LinodeInterface, preserveKnown bool, diags *diag.Diagnostics, +) { + data.ID = helper.KeepOrUpdateString(data.ID, strconv.Itoa(i.ID), preserveKnown) + + flattenedDefaultRoute := helper.KeepOrUpdateSingleNestedAttributes( + ctx, data.DefaultRoute, preserveKnown, diags, func(dr *DefaultRouteAttrModel, isNull *bool, pk bool, _ *diag.Diagnostics) { + if i.DefaultRoute == nil { + dr.IPv4 = helper.KeepOrUpdateValue(dr.IPv4, types.BoolNull(), pk) + dr.IPv6 = helper.KeepOrUpdateValue(dr.IPv6, types.BoolNull(), pk) + *isNull = true + return + } + dr.FlattenInterfaceDefaultRoute(*i.DefaultRoute, pk) + }, + ) + + if diags.HasError() { + return + } + + data.DefaultRoute = *flattenedDefaultRoute + + flattenedVLAN := helper.KeepOrUpdateSingleNestedAttributes( + ctx, data.VLAN, preserveKnown, diags, func(vlan *VLANAttrModel, isNull *bool, pk bool, d *diag.Diagnostics) { + if i.VLAN == nil { + *isNull = true + vlan.IPAMAddress = helper.KeepOrUpdateValue(vlan.IPAMAddress, cidrtypes.NewIPv4PrefixNull(), pk) + vlan.VLANLabel = helper.KeepOrUpdateValue(vlan.VLANLabel, types.StringNull(), pk) + return + } + vlan.FlattenVLANInterface(*i.VLAN, pk) + }, + ) + if diags.HasError() { + return + } + + data.VLAN = *flattenedVLAN + flattenedPublic := helper.KeepOrUpdateSingleNestedAttributesWithTypes( + ctx, data.Public, publicInterfaceSchema.GetType().(types.ObjectType).AttrTypes, preserveKnown, diags, + func(public *PublicAttrModel, isNull *bool, pk bool, d *diag.Diagnostics) { + if i.Public == nil { + *isNull = true + public.IPv4 = helper.KeepOrUpdateValue(public.IPv4, types.ObjectNull(publicIPv4Attribute.GetType().(types.ObjectType).AttrTypes), pk) + public.IPv6 = helper.KeepOrUpdateValue(public.IPv6, types.ObjectNull(publicIPv6Attribute.GetType().(types.ObjectType).AttrTypes), pk) + return + } + public.FlattenPublicInterface(ctx, *i.Public, pk, d) + }, + ) + if diags.HasError() { + return + } + + data.Public = *flattenedPublic + + // Flatten VPC interface + flattenedVPC := helper.KeepOrUpdateSingleNestedAttributesWithTypes( + ctx, data.VPC, vpcInterfaceSchema.GetType().(types.ObjectType).AttrTypes, preserveKnown, diags, + func(vpc *VPCAttrModel, isNull *bool, pk bool, d *diag.Diagnostics) { + if i.VPC == nil { + *isNull = true + vpc.IPv4 = helper.KeepOrUpdateValue( + vpc.IPv4, types.ObjectNull(vpcIPv4Attribute.GetType().(types.ObjectType).AttrTypes), pk, + ) + vpc.SubnetID = helper.KeepOrUpdateValue(vpc.SubnetID, types.Int64Null(), pk) + return + } + vpc.FlattenVPCInterface(ctx, *i.VPC, pk, d) + }, + ) + if diags.HasError() { + return + } + + data.VPC = *flattenedVPC +} diff --git a/linode/linodeinterface/framework_public_models.go b/linode/linodeinterface/framework_public_models.go new file mode 100644 index 000000000..8e0b342c0 --- /dev/null +++ b/linode/linodeinterface/framework_public_models.go @@ -0,0 +1,288 @@ +package linodeinterface + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type PublicAttrModel struct { + IPv4 types.Object `tfsdk:"ipv4"` + IPv6 types.Object `tfsdk:"ipv6"` +} + +type PublicIPv4AttrModel struct { + Addresses types.List `tfsdk:"addresses"` + AssignedAddresses types.Set `tfsdk:"assigned_addresses"` + Shared types.Set `tfsdk:"shared"` +} + +type PublicIPv6AttrModel struct { + Ranges types.List `tfsdk:"ranges"` + AssignedRanges types.Set `tfsdk:"assigned_ranges"` + Shared types.Set `tfsdk:"shared"` + SLAAC types.Set `tfsdk:"slaac"` +} + +type SharedPublicIPv4AddressAttrModel struct { + Address types.String `tfsdk:"address"` + LinodeID types.Int64 `tfsdk:"linode_id"` +} + +// PublicIPv4AddressAttrModel is a shared model between `configuredPublicInterfaceIPv4Address` and +// `computedPublicInterfaceIPv4Address` schemas. +type PublicIPv4AddressAttrModel struct { + Address types.String `tfsdk:"address"` + Primary types.Bool `tfsdk:"primary"` +} + +// PublicIPv6RangeAttrModel is a shared model between `configuredPublicInterfaceIPv6Range` and +// `computedPublicInterfaceIPv6Range` schemas. +type PublicIPv6RangeAttrModel struct { + Range types.String `tfsdk:"range"` + RouteTarget types.String `tfsdk:"route_target"` +} + +type PublicIPv6SLAACAttrModel struct { + Address types.String `tfsdk:"address"` + Prefix types.Int64 `tfsdk:"prefix"` +} + +func (plan *PublicAttrModel) GetCreateOrUpdateOptions( + ctx context.Context, + state *PublicAttrModel, +) (opts linodego.PublicInterfaceCreateOptions, shouldUpdate bool) { + if !plan.IPv4.IsUnknown() && !plan.IPv4.IsNull() && (state == nil || !state.IPv4.Equal(plan.IPv4)) { + var planPublicIPv4 PublicIPv4AttrModel + plan.IPv4.As(ctx, &planPublicIPv4, basetypes.ObjectAsOptions{}) + opts.IPv4 = linodego.Pointer(planPublicIPv4.GetCreateOptions(ctx)) + shouldUpdate = true + } + + if !plan.IPv6.IsUnknown() && !plan.IPv6.IsNull() && (state == nil || !state.IPv6.Equal(plan.IPv6)) { + var planPublicIPv6 PublicIPv6AttrModel + plan.IPv6.As(ctx, &planPublicIPv6, basetypes.ObjectAsOptions{}) + opts.IPv6 = linodego.Pointer(planPublicIPv6.GetCreateOptions(ctx)) + shouldUpdate = true + } + + return opts, shouldUpdate +} + +func (plan *PublicIPv4AttrModel) GetCreateOptions(ctx context.Context) (opts linodego.PublicInterfaceIPv4CreateOptions) { + if !plan.Addresses.IsNull() && !plan.Addresses.IsUnknown() { + length := len(plan.Addresses.Elements()) + addressesOpts := make([]linodego.PublicInterfaceIPv4AddressCreateOptions, 0, length) + + addresses := make([]PublicIPv4AddressAttrModel, 0, length) + + // Since `addresses` list is with a default value of empty list, we may safely assume + // its elements won't contain any unknown and null + plan.Addresses.ElementsAs(ctx, &addresses, false) + + for _, v := range addresses { + addressesOpts = append(addressesOpts, v.GetCreateOptions()) + } + opts.Addresses = linodego.Pointer(addressesOpts) + } + + return opts +} + +func (data *PublicIPv4AttrModel) FlattenPublicIPv4(ctx context.Context, ipv4 linodego.PublicInterfaceIPv4, preserveKnown bool, diags *diag.Diagnostics) { + // data.Address should never need to be flattened from a linodego struct because its values can + // either be configured by the TF practitioner or defaulted to an empty list + + // when it's null, the types of attributes of the object won't be filled by object.As(...), resetting manually here + // TODO: filing a bugfix/feature request to HashiCorp + if data.Addresses.IsNull() { + data.Addresses = types.ListNull(configuredPublicInterfaceIPv4Address.Type()) + } + + var newDiags diag.Diagnostics + assignedAddresses := make([]PublicIPv4AddressAttrModel, len(ipv4.Addresses)) + for i, v := range ipv4.Addresses { + assignedAddresses[i] = PublicIPv4AddressAttrModel{ + Address: types.StringValue(v.Address), + Primary: types.BoolValue(v.Primary), + } + } + + // Each object in the `assigned_addresses` set attribute is computed-only without UseStateForUnknown plan modifier, + // so it's always an unknown. Thus, no need to check the nested attributes of these objects + newAssignedAddresses, newDiags := types.SetValueFrom(ctx, computedPublicInterfaceIPv4Address.Type(), assignedAddresses) + diags.Append(newDiags...) + if diags.HasError() { + return + } + + data.AssignedAddresses = helper.KeepOrUpdateValue(data.AssignedAddresses, newAssignedAddresses, preserveKnown) + + shared := make([]SharedPublicIPv4AddressAttrModel, len(ipv4.Shared)) + for i, v := range ipv4.Shared { + shared[i] = SharedPublicIPv4AddressAttrModel{ + Address: types.StringValue(v.Address), + LinodeID: types.Int64Value(int64(v.LinodeID)), + } + } + + // Each object in the `shared` set attribute is computed-only without UseStateForUnknown plan modifier, + // so it's always an unknown. Thus, no need to check the nested attributes of these objects. + newShared, newDiags := types.SetValueFrom(ctx, sharedPublicInterfaceIPv4Address.Type(), shared) + diags.Append(newDiags...) + if diags.HasError() { + return + } + + data.Shared = helper.KeepOrUpdateValue(data.Shared, newShared, preserveKnown) +} + +func (data *PublicIPv6AttrModel) FlattenPublicIPv6(ctx context.Context, ipv6 linodego.PublicInterfaceIPv6, preserveKnown bool, diags *diag.Diagnostics) { + // data.Ranges should never need to be flattened from a linodego struct because its values can + // either be configured by the TF practitioner or defaulted to an empty list + + // when it's null, the types of attributes of the object won't be filled by object.As(...), resetting manually here + // TODO: filing a bugfix/feature request to HashiCorp + if data.Ranges.IsNull() { + data.Ranges = types.ListNull(configuredPublicInterfaceIPv6Range.Type()) + } + + var newDiags diag.Diagnostics + assignedRanges := make([]PublicIPv6RangeAttrModel, len(ipv6.Ranges)) + for i, v := range ipv6.Ranges { + assignedRanges[i] = PublicIPv6RangeAttrModel{ + Range: types.StringValue(v.Range), + RouteTarget: types.StringPointerValue(v.RouteTarget), + } + } + + // `assigned_ranges` attribute is computed-only so it's always an unknown when the ipv6 is being flattened + data.AssignedRanges, newDiags = types.SetValueFrom(ctx, computedPublicInterfaceIPv6Range.Type(), assignedRanges) + diags.Append(newDiags...) + if diags.HasError() { + return + } + + shared := make([]PublicIPv6RangeAttrModel, len(ipv6.Shared)) + for i, v := range ipv6.Shared { + shared[i] = PublicIPv6RangeAttrModel{ + Range: types.StringValue(v.Range), + RouteTarget: types.StringPointerValue(v.RouteTarget), + } + } + + // `shared` attribute is computed-only so it's always an unknown when the ipv6 is being flattened + data.Shared, newDiags = types.SetValueFrom(ctx, computedPublicInterfaceIPv6Range.Type(), shared) + diags.Append(newDiags...) + if diags.HasError() { + return + } + + slaac := make([]PublicIPv6SLAACAttrModel, len(ipv6.SLAAC)) + for i, v := range ipv6.SLAAC { + slaac[i] = PublicIPv6SLAACAttrModel{ + Address: types.StringValue(v.Address), + Prefix: types.Int64Value(int64(v.Prefix)), + } + } + + // `slaac` attribute is computed-only so it's always an unknown when the ipv6 is being flattened + data.SLAAC, newDiags = types.SetValueFrom(ctx, publicInterfaceIPv6SLAAC.Type(), slaac) + diags.Append(newDiags...) + if diags.HasError() { + return + } +} + +func (plan *PublicIPv6AttrModel) GetCreateOptions(ctx context.Context) (opts linodego.PublicInterfaceIPv6CreateOptions) { + if !plan.Ranges.IsNull() && !plan.Ranges.IsUnknown() { + length := len(plan.Ranges.Elements()) + + rangesOpts := make([]linodego.PublicInterfaceIPv6RangeCreateOptions, 0, length) + ranges := make([]PublicIPv6RangeAttrModel, 0, length) + + // Since `ranges` list is with a default value of empty list, we may safely assume + // its elements won't contain any unknown and null + plan.Ranges.ElementsAs(ctx, &ranges, false) + + for _, v := range ranges { + rangesOpts = append(rangesOpts, v.GetCreateOptions()) + } + opts.Ranges = linodego.Pointer(rangesOpts) + } + + return opts +} + +func (plan *PublicIPv4AddressAttrModel) GetCreateOptions() (opts linodego.PublicInterfaceIPv4AddressCreateOptions) { + opts.Address = helper.ValueStringPointerWithUnknownToNil(plan.Address) + opts.Primary = helper.ValueBoolPointerWithUnknownToNil(plan.Primary) + return opts +} + +func (plan *PublicIPv6RangeAttrModel) GetCreateOptions() (opts linodego.PublicInterfaceIPv6RangeCreateOptions) { + opts.Range = plan.Range.ValueString() + return opts +} + +func (data *PublicAttrModel) FlattenPublicInterface( + ctx context.Context, publicInterface linodego.PublicInterface, preserveKnown bool, diags *diag.Diagnostics, +) { + flattenedPublicIPv4 := helper.KeepOrUpdateSingleNestedAttributesWithTypes( + ctx, data.IPv4, publicIPv4Attribute.GetType().(types.ObjectType).AttrTypes, preserveKnown, diags, + func(publicIPv4 *PublicIPv4AttrModel, isNull *bool, pk bool, d *diag.Diagnostics) { + if publicInterface.IPv4 == nil { + *isNull = true + publicIPv4.Addresses = helper.KeepOrUpdateValue( + publicIPv4.Addresses, types.ListNull(configuredPublicInterfaceIPv4Address.GetAttributes().Type()), pk, + ) + publicIPv4.AssignedAddresses = helper.KeepOrUpdateValue( + publicIPv4.AssignedAddresses, types.SetNull(computedPublicInterfaceIPv4Address.GetAttributes().Type()), pk, + ) + publicIPv4.Shared = helper.KeepOrUpdateValue( + publicIPv4.Shared, types.SetNull(sharedPublicInterfaceIPv4Address.GetAttributes().Type()), pk, + ) + return + } + + publicIPv4.FlattenPublicIPv4(ctx, *publicInterface.IPv4, pk, d) + }, + ) + if diags.HasError() { + return + } + + data.IPv4 = *flattenedPublicIPv4 + + flattenedPublicIPv6 := helper.KeepOrUpdateSingleNestedAttributesWithTypes( + ctx, data.IPv6, publicIPv6Attribute.GetType().(types.ObjectType).AttrTypes, preserveKnown, diags, + func(publicIPv6 *PublicIPv6AttrModel, isNull *bool, pk bool, d *diag.Diagnostics) { + if publicInterface.IPv6 == nil { + *isNull = true + publicIPv6.Ranges = helper.KeepOrUpdateValue( + publicIPv6.Ranges, types.ListNull(configuredPublicInterfaceIPv6Range.GetAttributes().Type()), pk, + ) + publicIPv6.AssignedRanges = helper.KeepOrUpdateValue( + publicIPv6.AssignedRanges, types.SetNull(computedPublicInterfaceIPv6Range.GetAttributes().Type()), pk, + ) + publicIPv6.Shared = helper.KeepOrUpdateValue( + publicIPv6.Shared, types.SetNull(computedPublicInterfaceIPv6Range.GetAttributes().Type()), pk, + ) + publicIPv6.SLAAC = helper.KeepOrUpdateValue( + publicIPv6.SLAAC, types.SetNull(publicInterfaceIPv6SLAAC.GetAttributes().Type()), pk, + ) + return + } + publicIPv6.FlattenPublicIPv6(ctx, *publicInterface.IPv6, pk, d) + }, + ) + if diags.HasError() { + return + } + + data.IPv6 = *flattenedPublicIPv6 +} diff --git a/linode/linodeinterface/framework_resource.go b/linode/linodeinterface/framework_resource.go new file mode 100644 index 000000000..72e6c0077 --- /dev/null +++ b/linode/linodeinterface/framework_resource.go @@ -0,0 +1,248 @@ +package linodeinterface + +import ( + "context" + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "linode_interface", + IDType: types.StringType, + Schema: &frameworkResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func AddInterfaceResource(ctx context.Context, i linodego.LinodeInterface, resp *resource.CreateResponse, plan LinodeInterfaceModel) { + resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(strconv.Itoa(i.ID))) + resp.State.SetAttribute(ctx, path.Root("linode_id"), plan.LinodeID) +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + tflog.Debug(ctx, "Create "+r.Config.Name) + + var plan LinodeInterfaceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + helper.SetLogFieldBulk(ctx, map[string]any{"linode_id": plan.LinodeID}) + client := r.Meta.Client + + opts, linodeID := plan.GetCreateOptions(ctx, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + i, err := client.CreateInterface(ctx, linodeID, opts) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to Create a Network Interface for Linode Instance %d", linodeID), + err.Error(), + ) + return + } + + // Add resource to TF states earlier to prevent dangling resources + // (resources created but not managed by TF) when a later step fails. + AddInterfaceResource(ctx, *i, resp, plan) + + // IDs should always be overridden during creation (see #1085) + // TODO: Remove when Crossplane empty string ID issue is resolved + plan.ID = types.StringValue(strconv.Itoa(i.ID)) + + plan.FlattenInterface(ctx, *i, true, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + tflog.Debug(ctx, "Read "+r.Config.Name) + + var state LinodeInterfaceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if helper.FrameworkAttemptRemoveResourceForEmptyID(ctx, state.ID, resp) { + return + } + + ctx = populateLogAttributes(ctx, state) + + linodeID, id := state.GetIDs(&resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + linodeInterface, err := client.GetInterface(ctx, linodeID, id) + if err != nil { + if linodego.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Linode Interface No Longer Exists", + fmt.Sprintf("Linode Interface %v does not exist, removing it from state.", id), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to Get Linode Interface %v", id), + err.Error(), + ) + return + } + + state.FlattenInterface(ctx, *linodeInterface, false, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + tflog.Debug(ctx, "Update "+r.Config.Name) + + var plan, state LinodeInterfaceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + ctx = populateLogAttributes(ctx, state) + + linodeID, id := state.GetIDs(&resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + opts := plan.GetUpdateOptions(ctx, state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + i, err := client.UpdateInterface(ctx, linodeID, id, opts) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to Update Linode Interface %d", id), + err.Error(), + ) + return + } + + // Workaround for Crossplane issue where ID is not + // properly populated in plan + // See TPT-2865 for more details + if plan.ID.ValueString() == "" { + plan.ID = state.ID + } + + plan.FlattenInterface(ctx, *i, true, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + // plan.CopyFrom(ctx, state, &resp.Diagnostics, true) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + tflog.Debug(ctx, "Delete "+r.Config.Name) + + var state LinodeInterfaceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + client := r.Meta.Client + + linodeID, id := state.GetIDs(&resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + err := client.DeleteInterface(ctx, linodeID, id) + if err != nil { + if linodego.IsNotFound(err) { + resp.Diagnostics.AddWarning( + "Linode Interface No Longer Exists", + fmt.Sprintf("Linode Interface %v does not exist, removing it from state.", id), + ) + return + } + resp.Diagnostics.AddError( + "Failed to Delete Linode Interface", + err.Error(), + ) + return + } +} + +func (r *Resource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + tflog.Debug(ctx, "Import "+r.Config.Name) + helper.ImportStateWithMultipleIDs( + ctx, req, resp, + []helper.ImportableID{ + { + Name: "linode_id", + TypeConverter: helper.IDTypeConverterInt64, + }, + { + Name: "id", + TypeConverter: helper.IDTypeConverterString, + }, + }, + ) +} + +func populateLogAttributes(ctx context.Context, model LinodeInterfaceModel) context.Context { + return helper.SetLogFieldBulk(ctx, map[string]any{ + "linode_id": model.LinodeID.ValueInt64(), + "id": model.ID.ValueString(), + }) +} diff --git a/linode/linodeinterface/framework_resource_schema.go b/linode/linodeinterface/framework_resource_schema.go new file mode 100644 index 000000000..f69bec297 --- /dev/null +++ b/linode/linodeinterface/framework_resource_schema.go @@ -0,0 +1,395 @@ +package linodeinterface + +import ( + "github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + linodesetplanmodifier "github.com/linode/terraform-provider-linode/v3/linode/helper/setplanmodifiers" +) + +var configuredPublicInterfaceIPv4Address = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "address": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("auto"), + }, + "primary": schema.BoolAttribute{ + Optional: true, + }, + }, +} + +var computedPublicInterfaceIPv4Address = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "address": schema.StringAttribute{ + Computed: true, + }, + "primary": schema.BoolAttribute{ + Computed: true, + }, + }, +} + +var sharedPublicInterfaceIPv4Address = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "address": schema.StringAttribute{ + Computed: true, + }, + "linode_id": schema.Int64Attribute{ + Computed: true, + }, + }, +} + +var configuredPublicInterfaceIPv6Range = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "range": schema.StringAttribute{ + Required: true, + }, + "route_target": schema.StringAttribute{ + Description: "The public IPv6 address that the range is routed to.", + Optional: true, + }, + }, +} + +var computedPublicInterfaceIPv6Range = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "range": schema.StringAttribute{ + Computed: true, + }, + "route_target": schema.StringAttribute{ + Description: "The public IPv6 address that the range is routed to.", + Computed: true, + }, + }, +} + +var publicInterfaceIPv6SLAAC = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "address": schema.StringAttribute{ + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Computed: true, + }, + "prefix": schema.Int64Attribute{ + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Computed: true, + }, + }, +} + +var configuredVPCInterfaceIPv4Address = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "address": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("auto"), + }, + "primary": schema.BoolAttribute{ + Optional: true, + }, + "nat_1_1_address": schema.StringAttribute{ + Description: "The 1:1 NAT IPv4 address used to associate a public " + + "IPv4 address with the interface's VPC subnet IPv4 address.", + Optional: true, + }, + }, +} + +var computedVPCInterfaceIPv4Address = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "address": schema.StringAttribute{ + Computed: true, + }, + "primary": schema.BoolAttribute{ + Computed: true, + }, + "nat_1_1_address": schema.StringAttribute{ + Description: "The assigned 1:1 NAT IPv4 address used to associate " + + "a public IPv4 address with the interface's VPC subnet IPv4 " + + "address, calculated from `nat_1_1_address`.", + Computed: true, + }, + }, +} + +var configuredVPCInterfaceIPv4Range = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "range": schema.StringAttribute{ + Required: true, + }, + }, +} + +var computedVPCInterfaceIPv4Range = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "range": schema.StringAttribute{ + Computed: true, + }, + }, +} + +var publicIPv4Attribute = schema.SingleNestedAttribute{ + Description: "IPv4 addresses for this interface.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "addresses": schema.ListNestedAttribute{ + Description: "IPv4 addresses configured for this Linode interface.", + Optional: true, + NestedObject: configuredPublicInterfaceIPv4Address, + Validators: []validator.List{ + listvalidator.NoNullValues(), + }, + }, + "assigned_addresses": schema.SetNestedAttribute{ + Description: "The IPv4 address exclusively assigned to this Linode interface.", + Computed: true, + NestedObject: computedPublicInterfaceIPv4Address, + PlanModifiers: []planmodifier.Set{ + linodesetplanmodifier.UseStateForUnknownUnlessTheseChanged( + path.MatchRoot("public").AtName("ipv4").AtName("addresses"), + ), + }, + }, + "shared": schema.SetNestedAttribute{ + Description: "The IPv4 address assigned to this Linode interface, which is also shared with another Linode.", + Computed: true, + NestedObject: sharedPublicInterfaceIPv4Address, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + }, + }, +} + +var publicIPv6Attribute = schema.SingleNestedAttribute{ + Description: "IPv6 addresses for this interface.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "ranges": schema.ListNestedAttribute{ + Description: "Configured IPv6 range in CIDR notation (2600:0db8::1/64) or prefix-only (/64).", + Optional: true, + NestedObject: configuredPublicInterfaceIPv6Range, + Validators: []validator.List{ + listvalidator.NoNullValues(), + }, + }, + "assigned_ranges": schema.SetNestedAttribute{ + Description: "The IPv6 ranges exclusively assigned to this Linode interface.", + Computed: true, + NestedObject: computedPublicInterfaceIPv6Range, + PlanModifiers: []planmodifier.Set{ + linodesetplanmodifier.UseStateForUnknownUnlessTheseChanged( + path.MatchRoot("public").AtName("ipv6").AtName("ranges"), + ), + }, + }, + "shared": schema.SetNestedAttribute{ + Description: "The IPv6 address assigned to this Linode interface, which is also shared with another Linode.", + Computed: true, + NestedObject: computedPublicInterfaceIPv6Range, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + }, + "slaac": schema.SetNestedAttribute{ + Description: "The public slaac and subnet prefix settings for this public interface that is used to " + + "communicate over the public internet, and with other services in the same data center.", + Computed: true, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + NestedObject: publicInterfaceIPv6SLAAC, + }, + }, +} + +var publicInterfaceSchema = schema.SingleNestedAttribute{ + Description: "Linode public interface.", + Optional: true, + Validators: []validator.Object{ + objectvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("vlan"), + path.MatchRelative().AtParent().AtName("vpc"), + ), + }, + Attributes: map[string]schema.Attribute{ + "ipv4": publicIPv4Attribute, + "ipv6": publicIPv6Attribute, + }, +} + +var vpcIPv4Attribute = schema.SingleNestedAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "addresses": schema.ListNestedAttribute{ + Description: "Specifies the IPv4 addresses to use in the VPC subnet.", + Optional: true, + NestedObject: configuredVPCInterfaceIPv4Address, + Validators: []validator.List{ + listvalidator.NoNullValues(), + }, + }, + "assigned_addresses": schema.SetNestedAttribute{ + Description: "Assigned IPv4 addresses to use in the VPC subnet, calculated from `addresses` input.", + Computed: true, + NestedObject: computedVPCInterfaceIPv4Address, + PlanModifiers: []planmodifier.Set{ + linodesetplanmodifier.UseStateForUnknownUnlessTheseChanged( + path.MatchRoot("vpc").AtName("ipv4").AtName("addresses"), + ), + }, + }, + "ranges": schema.ListNestedAttribute{ + Description: "CIDR notation of a range (1.2.3.4/24) or prefix only (/24).", + Optional: true, + NestedObject: configuredVPCInterfaceIPv4Range, + Validators: []validator.List{ + listvalidator.NoNullValues(), + }, + }, + "assigned_ranges": schema.SetNestedAttribute{ + Description: "Assigned IPv4 ranges to use in the VPC subnet, calculated from `ranges` input.", + Computed: true, + NestedObject: computedVPCInterfaceIPv4Range, + PlanModifiers: []planmodifier.Set{ + linodesetplanmodifier.UseStateForUnknownUnlessTheseChanged( + path.MatchRoot("vpc").AtName("ipv4").AtName("ranges"), + ), + }, + }, + }, +} + +var vpcInterfaceSchema = schema.SingleNestedAttribute{ + Description: "Linode VPC interface.", + Optional: true, + Validators: []validator.Object{ + objectvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("public"), + path.MatchRelative().AtParent().AtName("vlan"), + ), + }, + Attributes: map[string]schema.Attribute{ + "ipv4": vpcIPv4Attribute, + "subnet_id": schema.Int64Attribute{ + Required: true, + Description: "The VPC subnet identifier for this interface.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + }, +} + +var frameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique ID for this interface.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Computed: true, + }, + "linode_id": schema.Int64Attribute{ + Description: "The ID of the Linode to assign this interface to.", + Required: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "firewall_id": schema.Int64Attribute{ + Description: "ID of an enabled firewall to secure a VPC or public interface. Not allowed for VLAN interfaces.", + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "default_route": schema.SingleNestedAttribute{ + Description: "Indicates if the interface serves as the default route when multiple interfaces are eligible for this role.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "ipv4": schema.BoolAttribute{ + Description: "If set to true, the interface is used for the IPv4 default route.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "ipv6": schema.BoolAttribute{ + Description: "If set to true, the interface is used for the IPv6 default route.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + }, + }, + "public": publicInterfaceSchema, + "vlan": schema.SingleNestedAttribute{ + Description: "Linode VLAN interface.", + Optional: true, + Validators: []validator.Object{ + objectvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("public"), + path.MatchRelative().AtParent().AtName("vpc"), + ), + }, + Attributes: map[string]schema.Attribute{ + "ipam_address": schema.StringAttribute{ + Description: "This VLAN interface's private IPv4 address in classless inter-domain routing (CIDR) notation.", + Optional: true, + CustomType: cidrtypes.IPv4PrefixType{}, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "vlan_label": schema.StringAttribute{ + Description: "The VLAN's unique label.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 64), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + }, + "vpc": vpcInterfaceSchema, + }, +} diff --git a/linode/linodeinterface/framework_resource_test.go b/linode/linodeinterface/framework_resource_test.go new file mode 100644 index 000000000..7bb6fd089 --- /dev/null +++ b/linode/linodeinterface/framework_resource_test.go @@ -0,0 +1,834 @@ +//go:build integration || linodeinterface + +package linodeinterface_test + +import ( + "context" + "fmt" + "log" + "strconv" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + linodeinstancetmpl "github.com/linode/terraform-provider-linode/v3/linode/acceptance/tmpl" + "github.com/linode/terraform-provider-linode/v3/linode/helper" + "github.com/linode/terraform-provider-linode/v3/linode/linodeinterface/tmpl" +) + +const testInterfaceResName = "linode_interface.test" + +var testRegion string + +func init() { + resource.AddTestSweepers("linode_interface", &resource.Sweeper{ + Name: "linode_interface", + F: sweep, + }) + + region, err := acceptance.GetRandomRegionWithCaps([]string{linodego.CapabilityLinodes, linodego.CapabilityVlans, linodego.CapabilityVPCs}, "core") + if err != nil { + log.Fatal(err) + } + + testRegion = region +} + +func sweep(prefix string) error { + client, err := acceptance.GetTestClient() + if err != nil { + return fmt.Errorf("failed to get client: %s", err) + } + + // Get all instances and sweep their interfaces + instances, err := client.ListInstances(context.Background(), nil) + if err != nil { + return fmt.Errorf("failed to get instances: %s", err) + } + + for _, instance := range instances { + if !acceptance.ShouldSweep(prefix, instance.Label) { + continue + } + + // Get instance configs to find interfaces + configs, err := client.ListInstanceConfigs(context.Background(), instance.ID, nil) + if err != nil { + continue // Skip if we can't get configs + } + + // Delete non-primary interfaces from configs + for _, config := range configs { + for i, iface := range config.Interfaces { + // Skip eth0 (primary interface) and other essential interfaces + if i == 0 || iface.Purpose == linodego.InterfacePurposePublic { + continue + } + + // For sweep purposes, we'll let the instance deletion handle interface cleanup + // since interfaces are tied to instances + } + } + } + + return nil +} + +func TestAccLinodeInterface_vlan_basic(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + vlanLabel := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VLANBasic(t, label, testRegion, vlanLabel, "192.168.200.5/24"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vlan").AtMapKey("vlan_label"), knownvalue.StringExact(vlanLabel)), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vlan").AtMapKey("ipam_address"), + knownvalue.StringExact("192.168.200.5/24"), + ), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + }, + }, + }) +} + +func TestAccLinodeInterface_public_basic(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.PublicBasic(t, label, testRegion), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("address"), + knownvalue.StringExact("auto"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("primary"), + knownvalue.Bool(true), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("assigned_addresses"), + knownvalue.NotNull(), + ), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"public.ipv4.addresses", "public.ipv6.ranges"}, + }, + }, + }) +} + +func TestAccLinodeInterface_public_ipv4_ipv6(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.PublicWithIPv4AndIPv6(t, label, testRegion, "auto", "/64"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("address"), + knownvalue.StringExact("auto"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("primary"), + knownvalue.Bool(true), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("ranges"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("ranges").AtSliceIndex(0).AtMapKey("range"), + knownvalue.StringExact("/64"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("assigned_addresses"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("assigned_ranges"), + knownvalue.NotNull(), + ), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"public.ipv4.addresses", "public.ipv6.ranges"}, + }, + }, + }) +} + +func TestAccLinodeInterface_public_ipv6_only(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.PublicWithIPv6(t, label, testRegion), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + // Verify IPv4 addresses is explicitly empty (not omitted) + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses"), + knownvalue.ListSizeExact(0), + ), + // Verify IPv6 ranges are configured + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("ranges"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("ranges").AtSliceIndex(0).AtMapKey("range"), + knownvalue.StringExact("/64"), + ), + // Verify no IPv4 addresses are assigned due to empty list + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("assigned_addresses"), + knownvalue.ListSizeExact(0), + ), + // Verify IPv6 ranges are assigned + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("assigned_ranges"), + knownvalue.NotNull(), + ), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"public.ipv4.addresses", "public.ipv6.ranges"}, + }, + }, + }) +} + +func TestAccLinodeInterface_public_update_addresses(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.PublicWithIPv4AndIPv6(t, label, testRegion, "auto", "/64"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("address"), + knownvalue.StringExact("auto"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("primary"), + knownvalue.Bool(true), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("ranges"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("ranges").AtSliceIndex(0).AtMapKey("range"), + knownvalue.StringExact("/64"), + ), + }, + Check: checkInterfaceExists, + }, + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.PublicUpdatedIPv4AndIPv6(t, label, testRegion, "auto", "auto", "/64", "/64"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses"), + knownvalue.ListSizeExact(2), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("address"), + knownvalue.StringExact("auto"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("primary"), + knownvalue.Bool(true), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(1).AtMapKey("address"), + knownvalue.StringExact("auto"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(1).AtMapKey("primary"), + knownvalue.Bool(false), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("ranges"), + knownvalue.ListSizeExact(2), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("ranges").AtSliceIndex(0).AtMapKey("range"), + knownvalue.StringExact("/64"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("public").AtMapKey("ipv6").AtMapKey("ranges").AtSliceIndex(1).AtMapKey("range"), + knownvalue.StringExact("/64"), + ), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"public.ipv4.addresses", "public.ipv6.ranges"}, + }, + }, + }) +} + +func TestAccLinodeInterface_vpc_basic(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCBasic(t, label, testRegion, "10.0.0.0/24"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("subnet_id"), knownvalue.NotNull()), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"vpc.ipv4.addresses"}, + }, + }, + }) +} + +func TestAccLinodeInterface_vpc_with_ipv4(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCWithIPv4(t, label, testRegion, "10.0.0.0/24", "auto"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("subnet_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("addresses"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("address"), + knownvalue.StringExact("auto"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("primary"), + knownvalue.Bool(true), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("assigned_addresses"), + knownvalue.NotNull(), + ), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"vpc.ipv4.addresses"}, + }, + }, + }) +} + +func TestAccLinodeInterface_vpc_update_ipv4(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCWithIPv4(t, label, testRegion, "10.0.0.0/24", "auto"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("subnet_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("addresses"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("address"), + knownvalue.StringExact("auto"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("primary"), + knownvalue.Bool(true), + ), + }, + Check: checkInterfaceExists, + }, + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCWithIPv4(t, label, testRegion, "10.0.0.0/24", "10.0.0.100"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("subnet_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("addresses"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("address"), + knownvalue.StringExact("10.0.0.100"), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("addresses").AtSliceIndex(0).AtMapKey("primary"), + knownvalue.Bool(true), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv4").AtMapKey("assigned_addresses"), + knownvalue.NotNull(), + ), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"vpc.ipv4.addresses"}, + }, + }, + }) +} + +func TestAccLinodeInterface_public_default_route(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.PublicDefaultRouteIPv6(t, label, testRegion), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("default_route").AtMapKey("ipv4"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("default_route").AtMapKey("ipv6"), knownvalue.Bool(true)), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"public.ipv4.addresses", "public.ipv6.ranges"}, + }, + }, + }) +} + +func TestAccLinodeInterface_vpc_default_route(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCDefaultRouteIPv4(t, label, testRegion, "10.0.0.0/24"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("subnet_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("default_route").AtMapKey("ipv4"), knownvalue.Bool(true)), + // VPC interfaces don't support IPv6, so we don't expect ipv6 field to be set + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"vpc.ipv4.addresses"}, + }, + }, + }) +} + +// Smoke test to run a subset of tests +func TestSmokeTests_interface(t *testing.T) { + tests := []struct { + name string + test func(*testing.T) + }{ + {"TestAccLinodeInterface_vlan_basic", TestAccLinodeInterface_vlan_basic}, + {"TestAccLinodeInterface_public_basic", TestAccLinodeInterface_public_basic}, + {"TestAccLinodeInterface_public_ipv6_only", TestAccLinodeInterface_public_ipv6_only}, + {"TestAccLinodeInterface_vpc_basic", TestAccLinodeInterface_vpc_basic}, + } + + for _, tt := range tests { + t.Run(tt.name, tt.test) + } +} + +// Helper function to check if interface exists +func checkInterfaceExists(s *terraform.State) error { + client := acceptance.TestAccSDKv2Provider.Meta().(*helper.ProviderMeta).Client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "linode_interface" { + continue + } + + id, err := strconv.Atoi(rs.Primary.ID) + if err != nil { + return fmt.Errorf("Error parsing Interface ID %v to int", rs.Primary.ID) + } + + linodeID, err := strconv.Atoi(rs.Primary.Attributes["linode_id"]) + if err != nil { + return fmt.Errorf("Error parsing Linode ID %v to int", rs.Primary.Attributes["linode_id"]) + } + + // Use second generation interface API to get the interface directly + _, err = client.GetInterface(context.Background(), linodeID, id) + if err != nil { + return fmt.Errorf("Error retrieving interface %d for instance %d: %s", id, linodeID, err) + } + } + + return nil +} + +// Helper function to check if interface is destroyed +func checkInterfaceDestroy(s *terraform.State) error { + client := acceptance.TestAccSDKv2Provider.Meta().(*helper.ProviderMeta).Client + for _, rs := range s.RootModule().Resources { + if rs.Type != "linode_interface" { + continue + } + + id, err := strconv.Atoi(rs.Primary.ID) + if err != nil { + return fmt.Errorf("Error parsing Interface ID %v to int", rs.Primary.ID) + } + + linodeID, err := strconv.Atoi(rs.Primary.Attributes["linode_id"]) + if err != nil { + return fmt.Errorf("Error parsing Linode ID %v to int", rs.Primary.Attributes["linode_id"]) + } + + if id == 0 { + // Don't try to delete interface 0 (primary interface) + continue + } + + // Use second generation interface API to check if interface still exists + _, err = client.GetInterface(context.Background(), linodeID, id) + if err != nil { + if apiErr, ok := err.(*linodego.Error); ok && apiErr.Code == 404 { + // Interface doesn't exist, which is expected after destroy + continue + } + return fmt.Errorf("Error checking interface %d for instance %d: %s", id, linodeID, err) + } + + // If we get here, the interface still exists + return fmt.Errorf("Interface with id %d still exists", id) + } + + return nil +} + +func TestAccLinodeInterface_vpc_default_ip(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCDefaultIP(t, label, testRegion, "10.0.0.0/24"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("subnet_id"), knownvalue.NotNull()), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"vpc.ipv4.addresses"}, + }, + }, + }) +} + +func TestAccLinodeInterface_public_default_ip(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.PublicDefaultIP(t, label, testRegion), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"public.ipv4.addresses", "public.ipv6.ranges"}, + }, + }, + }) +} + +func TestAccLinodeInterface_public_empty_ip_objects(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.PublicEmptyIPObjects(t, label, testRegion), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + // Note: addresses may be nil when empty, so just check the fields exist + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("public").AtMapKey("ipv4"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("public").AtMapKey("ipv6"), knownvalue.NotNull()), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"public.ipv4.addresses", "public.ipv6.ranges"}, + }, + }, + }) +} + +func TestAccLinodeInterface_vpc_empty_ip_objects(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCEmptyIPObjects(t, label, testRegion, "10.0.0.0/24"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("subnet_id"), knownvalue.NotNull()), + // Note: addresses may be nil when empty, so just check the ipv4 field exists + // VPC interfaces don't support IPv6, so we don't check for ipv6 + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("ipv4"), knownvalue.NotNull()), + }, + Check: checkInterfaceExists, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"vpc.ipv4.addresses"}, + }, + }, + }) +} + +func importStateID(s *terraform.State) (string, error) { + for _, rs := range s.RootModule().Resources { + if rs.Type != "linode_interface" { + continue + } + + linodeID := rs.Primary.Attributes["linode_id"] + id := rs.Primary.ID + if linodeID == "" || id == "" { + return "", fmt.Errorf("The id %q or linode_id %q is not set correctly", id, linodeID) + } + + return fmt.Sprintf("%s,%s", linodeID, id), nil + } + + return "", fmt.Errorf("Error finding linode_interface") +} diff --git a/linode/linodeinterface/framework_vlan_models.go b/linode/linodeinterface/framework_vlan_models.go new file mode 100644 index 000000000..b980de188 --- /dev/null +++ b/linode/linodeinterface/framework_vlan_models.go @@ -0,0 +1,35 @@ +package linodeinterface + +import ( + "github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type VLANAttrModel struct { + IPAMAddress cidrtypes.IPv4Prefix `tfsdk:"ipam_address"` + VLANLabel types.String `tfsdk:"vlan_label"` +} + +func (data *VLANAttrModel) CopyFrom(other VLANAttrModel, preserveKnown bool) { + data.IPAMAddress = helper.KeepOrUpdateValue(data.IPAMAddress, other.IPAMAddress, preserveKnown) + data.VLANLabel = helper.KeepOrUpdateValue(data.VLANLabel, other.VLANLabel, preserveKnown) +} + +func (plan *VLANAttrModel) GetCreateOptions() (vlan linodego.VLANInterface) { + if !plan.IPAMAddress.IsUnknown() { + vlan.IPAMAddress = plan.IPAMAddress.ValueStringPointer() + } + if !plan.VLANLabel.IsUnknown() { + vlan.VLANLabel = plan.VLANLabel.ValueString() + } + return vlan +} + +func (data *VLANAttrModel) FlattenVLANInterface( + vlanInterface linodego.VLANInterface, preserveKnown bool, +) { + data.VLANLabel = helper.KeepOrUpdateString(data.VLANLabel, vlanInterface.VLANLabel, preserveKnown) + data.IPAMAddress = helper.KeepOrUpdateValue(data.IPAMAddress, cidrtypes.NewIPv4PrefixPointerValue(vlanInterface.IPAMAddress), preserveKnown) +} diff --git a/linode/linodeinterface/framework_vpc_models.go b/linode/linodeinterface/framework_vpc_models.go new file mode 100644 index 000000000..911c9b274 --- /dev/null +++ b/linode/linodeinterface/framework_vpc_models.go @@ -0,0 +1,198 @@ +package linodeinterface + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type VPCAttrModel struct { + IPv4 types.Object `tfsdk:"ipv4"` + SubnetID types.Int64 `tfsdk:"subnet_id"` +} + +type VPCIPv4AttrModel struct { + Addresses types.List `tfsdk:"addresses"` + AssignedAddresses types.Set `tfsdk:"assigned_addresses"` + Ranges types.List `tfsdk:"ranges"` + AssignedRanges types.Set `tfsdk:"assigned_ranges"` +} + +// VPCIPv4AddressAttrModel is a shared model between `configuredVPCInterfaceIPv4Address` and +// `computedVPCInterfaceIPv4Address` +type VPCIPv4AddressAttrModel struct { + Address types.String `tfsdk:"address"` + Primary types.Bool `tfsdk:"primary"` + Nat11Address types.String `tfsdk:"nat_1_1_address"` +} + +// VPCIPv4RangeAttrModel is a shared model between `configuredVPCInterfaceIPv4Range` and +// `computedVPCInterfaceIPv4Range` +type VPCIPv4RangeAttrModel struct { + Range types.String `tfsdk:"range"` +} + +func (plan *VPCAttrModel) GetCreateOptions(ctx context.Context, diags *diag.Diagnostics) (opts linodego.VPCInterfaceCreateOptions) { + opts.SubnetID = helper.FrameworkSafeInt64ToInt(plan.SubnetID.ValueInt64(), diags) + + if !plan.IPv4.IsUnknown() && !plan.IPv4.IsNull() { + var planIPv4 VPCIPv4AttrModel + plan.IPv4.As(ctx, &planIPv4, basetypes.ObjectAsOptions{}) + ipv4Opts, _ := planIPv4.GetCreateOrUpdateOptions(ctx, nil) + opts.IPv4 = &ipv4Opts + } + + return opts +} + +func (plan *VPCAttrModel) GetUpdateOptions( + ctx context.Context, + state *VPCAttrModel, + diags *diag.Diagnostics, +) (opts linodego.VPCInterfaceUpdateOptions, shouldUpdate bool) { + // Note: SubnetID cannot be updated according to the API, so we don't include it + + if !plan.IPv4.IsUnknown() && !plan.IPv4.IsNull() { + var planIPv4 VPCIPv4AttrModel + plan.IPv4.As(ctx, &planIPv4, basetypes.ObjectAsOptions{}) + + var stateIPv4 *VPCIPv4AttrModel + if state != nil && !state.IPv4.IsNull() { + state.IPv4.As(ctx, &stateIPv4, basetypes.ObjectAsOptions{}) + } + + if ipv4Opts, ipv4ShouldUpdate := planIPv4.GetCreateOrUpdateOptions(ctx, stateIPv4); ipv4ShouldUpdate { + opts.IPv4 = &ipv4Opts + shouldUpdate = true + } + } + + return opts, shouldUpdate +} + +func (plan *VPCIPv4AttrModel) GetCreateOrUpdateOptions( + ctx context.Context, + state *VPCIPv4AttrModel, +) (opts linodego.VPCInterfaceIPv4CreateOptions, shouldUpdate bool) { + if !plan.Addresses.IsUnknown() && !plan.Addresses.IsNull() && (state == nil || !state.Addresses.Equal(plan.Addresses)) { + length := len(plan.Addresses.Elements()) + addresses := make([]VPCIPv4AddressAttrModel, 0, length) + plan.Addresses.ElementsAs(ctx, &addresses, false) + + addressOpts := make([]linodego.VPCInterfaceIPv4AddressCreateOptions, len(addresses)) + for i, address := range addresses { + addressOpts[i] = address.GetCreateOptions() + } + opts.Addresses = &addressOpts + shouldUpdate = true + } + + if !plan.Ranges.IsUnknown() && !plan.Ranges.IsNull() && (state == nil || !state.Ranges.Equal(plan.Ranges)) { + length := len(plan.Ranges.Elements()) + ranges := make([]VPCIPv4RangeAttrModel, 0, length) + plan.Ranges.ElementsAs(ctx, &ranges, false) + + rangeOpts := make([]linodego.VPCInterfaceIPv4RangeCreateOptions, len(ranges)) + for i, r := range ranges { + rangeOpts[i] = r.GetCreateOptions() + } + opts.Ranges = &rangeOpts + shouldUpdate = true + } + + return opts, shouldUpdate +} + +func (plan *VPCIPv4AddressAttrModel) GetCreateOptions() linodego.VPCInterfaceIPv4AddressCreateOptions { + opts := linodego.VPCInterfaceIPv4AddressCreateOptions{} + + if !plan.Address.IsUnknown() { + opts.Address = plan.Address.ValueStringPointer() + } + + if !plan.Primary.IsUnknown() { + opts.Primary = plan.Primary.ValueBoolPointer() + } + + if !plan.Nat11Address.IsUnknown() { + opts.NAT1To1Address = plan.Nat11Address.ValueStringPointer() + } + + return opts +} + +func (plan *VPCIPv4RangeAttrModel) GetCreateOptions() linodego.VPCInterfaceIPv4RangeCreateOptions { + return linodego.VPCInterfaceIPv4RangeCreateOptions{ + Range: plan.Range.ValueString(), + } +} + +func (data *VPCAttrModel) FlattenVPCInterface( + ctx context.Context, vpcInterface linodego.VPCInterface, preserveKnown bool, diags *diag.Diagnostics, +) { + data.SubnetID = helper.KeepOrUpdateInt64(data.SubnetID, int64(vpcInterface.SubnetID), preserveKnown) + + flattenedIPv4 := helper.KeepOrUpdateSingleNestedAttributesWithTypes( + ctx, data.IPv4, vpcIPv4Attribute.GetType().(basetypes.ObjectType).AttrTypes, preserveKnown, diags, + func(ipv4 *VPCIPv4AttrModel, isNull *bool, pk bool, d *diag.Diagnostics) { + ipv4.FlattenVPCIPv4(ctx, vpcInterface.IPv4, pk, d) + }, + ) + + if diags.HasError() { + return + } + + data.IPv4 = *flattenedIPv4 +} + +func (data *VPCIPv4AttrModel) FlattenVPCIPv4(ctx context.Context, ipv4 linodego.VPCInterfaceIPv4, preserveKnown bool, diags *diag.Diagnostics) { + // When the object is null/unknown, the types of attributes of the object won't be filled by object.As(...) in the + // helper function `KeepOrUpdateSingleNestedAttributeWithTypes`, so resetting manually here. + if data.Addresses.IsNull() { + data.Addresses = types.ListNull(configuredVPCInterfaceIPv4Address.Type()) + } + if data.Ranges.IsNull() { + data.Ranges = types.ListNull(configuredVPCInterfaceIPv4Range.Type()) + } + + assignedAddresses := make([]VPCIPv4AddressAttrModel, len(ipv4.Addresses)) + for i, addr := range ipv4.Addresses { + assignedAddresses[i] = VPCIPv4AddressAttrModel{ + Address: types.StringValue(addr.Address), + Primary: types.BoolValue(addr.Primary), + Nat11Address: types.StringPointerValue(addr.NAT1To1Address), + } + } + + assignedAddressesValue, assignedAddressesDiags := types.SetValueFrom( + ctx, computedVPCInterfaceIPv4Address.GetAttributes().Type(), assignedAddresses, + ) + diags.Append(assignedAddressesDiags...) + if diags.HasError() { + return + } + + data.AssignedAddresses = helper.KeepOrUpdateValue(data.AssignedAddresses, assignedAddressesValue, preserveKnown) + + assignedRanges := make([]VPCIPv4RangeAttrModel, len(ipv4.Ranges)) + for i, r := range ipv4.Ranges { + assignedRanges[i] = VPCIPv4RangeAttrModel{ + Range: types.StringValue(r.Range), + } + } + + assignedRangesValue, assignedRangesDiags := types.SetValueFrom( + ctx, computedVPCInterfaceIPv4Range.GetAttributes().Type(), assignedRanges, + ) + diags.Append(assignedRangesDiags...) + if diags.HasError() { + return + } + + data.AssignedRanges = helper.KeepOrUpdateValue(data.AssignedRanges, assignedRangesValue, preserveKnown) +} diff --git a/linode/linodeinterface/tmpl/public_basic.gotf b/linode/linodeinterface/tmpl/public_basic.gotf new file mode 100644 index 000000000..6da010a96 --- /dev/null +++ b/linode/linodeinterface/tmpl/public_basic.gotf @@ -0,0 +1,25 @@ +{{ define "interface_public_basic" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + public = { + ipv4 = { + addresses = [ + { + address = "auto" + primary = true + } + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/public_default_ip.gotf b/linode/linodeinterface/tmpl/public_default_ip.gotf new file mode 100644 index 000000000..f4aa7b98b --- /dev/null +++ b/linode/linodeinterface/tmpl/public_default_ip.gotf @@ -0,0 +1,16 @@ +{{ define "interface_public_default_ip" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + public = {} +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/public_default_route_ipv6.gotf b/linode/linodeinterface/tmpl/public_default_route_ipv6.gotf new file mode 100644 index 000000000..0366fbe56 --- /dev/null +++ b/linode/linodeinterface/tmpl/public_default_route_ipv6.gotf @@ -0,0 +1,36 @@ +{{ define "public_default_route_ipv6" }} + +resource "linode_instance" "test" { + label = "{{ .Label }}" + region = "{{ .Region }}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + public = { + ipv4 = { + addresses = [ + { + address = "auto" + } + ] + } + ipv6 = { + ranges = [ + { + range = "/64" + } + ] + } + } + + default_route = { + ipv4 = true + ipv6 = true + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/public_empty_ip_objects.gotf b/linode/linodeinterface/tmpl/public_empty_ip_objects.gotf new file mode 100644 index 000000000..c98a289e7 --- /dev/null +++ b/linode/linodeinterface/tmpl/public_empty_ip_objects.gotf @@ -0,0 +1,19 @@ +{{ define "interface_public_empty_ip_objects" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + public = { + ipv4 = {} + ipv6 = {} + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/public_ipv4.gotf b/linode/linodeinterface/tmpl/public_ipv4.gotf new file mode 100644 index 000000000..d41887b45 --- /dev/null +++ b/linode/linodeinterface/tmpl/public_ipv4.gotf @@ -0,0 +1,25 @@ +{{ define "interface_public_ipv4" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + public = { + ipv4 = { + addresses = [ + { + address = "auto" + primary = true + } + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/public_ipv4_ipv6.gotf b/linode/linodeinterface/tmpl/public_ipv4_ipv6.gotf new file mode 100644 index 000000000..e24bcf015 --- /dev/null +++ b/linode/linodeinterface/tmpl/public_ipv4_ipv6.gotf @@ -0,0 +1,32 @@ +{{ define "interface_public_ipv4_ipv6" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + public = { + ipv4 = { + addresses = [ + { + address = "{{.IPv4Address}}" + primary = true + } + ] + } + ipv6 = { + ranges = [ + { + range = "{{.IPv6Range}}" + } + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/public_ipv6.gotf b/linode/linodeinterface/tmpl/public_ipv6.gotf new file mode 100644 index 000000000..52d07deeb --- /dev/null +++ b/linode/linodeinterface/tmpl/public_ipv6.gotf @@ -0,0 +1,27 @@ +{{ define "interface_public_ipv6" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + public = { + ipv4 = { + addresses = [] + } + ipv6 = { + ranges = [ + { + range = "/64" + } + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/public_updated_ipv4_ipv6.gotf b/linode/linodeinterface/tmpl/public_updated_ipv4_ipv6.gotf new file mode 100644 index 000000000..4065e68ce --- /dev/null +++ b/linode/linodeinterface/tmpl/public_updated_ipv4_ipv6.gotf @@ -0,0 +1,39 @@ +{{ define "interface_public_updated_ipv4_ipv6" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + public = { + ipv4 = { + addresses = [ + { + address = "{{.IPv4Address}}" + primary = true + }, + { + address = "{{.IPv4Address2}}" + primary = false + } + ] + } + ipv6 = { + ranges = [ + { + range = "{{.IPv6Range}}" + }, + { + range = "{{.IPv6Range2}}" + } + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/template.go b/linode/linodeinterface/tmpl/template.go new file mode 100644 index 000000000..c8e788dd5 --- /dev/null +++ b/linode/linodeinterface/tmpl/template.go @@ -0,0 +1,145 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + Label string + Region string + SubnetIPv4 string + VLANLabel string + IPAMAddress string + IPv4Address string + IPv6Range string + IPv4Address2 string + IPv6Range2 string +} + +func VLANBasic(t testing.TB, label, region, vlanLabel, ipamAddress string) string { + return acceptance.ExecuteTemplate(t, + "interface_vlan_basic", TemplateData{ + Label: label, + Region: region, + VLANLabel: vlanLabel, + IPAMAddress: ipamAddress, + }) +} + +func PublicBasic(t testing.TB, label, region string) string { + return acceptance.ExecuteTemplate(t, + "interface_public_basic", TemplateData{ + Label: label, + Region: region, + }) +} + +func PublicWithIPv4(t testing.TB, label, region string) string { + return acceptance.ExecuteTemplate(t, + "interface_public_ipv4", TemplateData{ + Label: label, + Region: region, + }) +} + +func PublicWithIPv6(t testing.TB, label, region string) string { + return acceptance.ExecuteTemplate(t, + "interface_public_ipv6", TemplateData{ + Label: label, + Region: region, + }) +} + +func PublicWithIPv4AndIPv6(t testing.TB, label, region, ipv4Address, ipv6Range string) string { + return acceptance.ExecuteTemplate(t, + "interface_public_ipv4_ipv6", TemplateData{ + Label: label, + Region: region, + IPv4Address: ipv4Address, + IPv6Range: ipv6Range, + }) +} + +func PublicUpdatedIPv4AndIPv6(t testing.TB, label, region, ipv4Address, ipv4Address2, ipv6Range, ipv6Range2 string) string { + return acceptance.ExecuteTemplate(t, + "interface_public_updated_ipv4_ipv6", TemplateData{ + Label: label, + Region: region, + IPv4Address: ipv4Address, + IPv4Address2: ipv4Address2, + IPv6Range: ipv6Range, + IPv6Range2: ipv6Range2, + }) +} + +func VPCBasic(t testing.TB, label, region, subnetIPv4 string) string { + return acceptance.ExecuteTemplate(t, + "interface_vpc_basic", TemplateData{ + Label: label, + Region: region, + SubnetIPv4: subnetIPv4, + }) +} + +func VPCWithIPv4(t testing.TB, label, region, subnetIPv4, ipAddress string) string { + return acceptance.ExecuteTemplate(t, + "interface_vpc_with_ipv4", TemplateData{ + Label: label, + Region: region, + SubnetIPv4: subnetIPv4, + IPv4Address: ipAddress, + }) +} + +func VPCDefaultIP(t testing.TB, label, region, subnetIPv4 string) string { + return acceptance.ExecuteTemplate(t, + "interface_vpc_default_ip", TemplateData{ + Label: label, + Region: region, + SubnetIPv4: subnetIPv4, + }) +} + +func PublicDefaultIP(t testing.TB, label, region string) string { + return acceptance.ExecuteTemplate(t, + "interface_public_default_ip", TemplateData{ + Label: label, + Region: region, + }) +} + +func PublicEmptyIPObjects(t testing.TB, label, region string) string { + return acceptance.ExecuteTemplate(t, + "interface_public_empty_ip_objects", TemplateData{ + Label: label, + Region: region, + }) +} + +func VPCEmptyIPObjects(t testing.TB, label, region, subnetIPv4 string) string { + return acceptance.ExecuteTemplate(t, + "interface_vpc_empty_ip_objects", TemplateData{ + Label: label, + Region: region, + SubnetIPv4: subnetIPv4, + }) +} + +func PublicDefaultRouteIPv6(t testing.TB, label, region string) string { + return acceptance.ExecuteTemplate(t, + "public_default_route_ipv6", TemplateData{ + Label: label, + Region: region, + }) +} + +func VPCDefaultRouteIPv4(t testing.TB, label, region, subnetIPv4 string) string { + return acceptance.ExecuteTemplate(t, + "vpc_default_route_ipv4", TemplateData{ + Label: label, + Region: region, + SubnetIPv4: subnetIPv4, + }) +} diff --git a/linode/linodeinterface/tmpl/vlan_basic.gotf b/linode/linodeinterface/tmpl/vlan_basic.gotf new file mode 100644 index 000000000..69355b898 --- /dev/null +++ b/linode/linodeinterface/tmpl/vlan_basic.gotf @@ -0,0 +1,19 @@ +{{ define "interface_vlan_basic" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + vlan = { + vlan_label = "{{.VLANLabel}}" + ipam_address = "{{.IPAMAddress}}" + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/vpc_basic.gotf b/linode/linodeinterface/tmpl/vpc_basic.gotf new file mode 100644 index 000000000..d2524d3a2 --- /dev/null +++ b/linode/linodeinterface/tmpl/vpc_basic.gotf @@ -0,0 +1,30 @@ +{{ define "interface_vpc_basic" }} + +resource "linode_vpc" "test" { + label = "{{.Label}}-vpc" + region = "{{.Region}}" + description = "vpc for interface testing" +} + +resource "linode_vpc_subnet" "test" { + vpc_id = linode_vpc.test.id + label = "{{.Label}}-subnet" + ipv4 = "{{.SubnetIPv4}}" +} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + vpc = { + subnet_id = linode_vpc_subnet.test.id + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/vpc_default_ip.gotf b/linode/linodeinterface/tmpl/vpc_default_ip.gotf new file mode 100644 index 000000000..15289d943 --- /dev/null +++ b/linode/linodeinterface/tmpl/vpc_default_ip.gotf @@ -0,0 +1,30 @@ +{{ define "interface_vpc_default_ip" }} + +resource "linode_vpc" "test" { + label = "{{.Label}}-vpc" + region = "{{.Region}}" + description = "vpc for interface testing" +} + +resource "linode_vpc_subnet" "test" { + vpc_id = linode_vpc.test.id + label = "{{.Label}}-subnet" + ipv4 = "{{.SubnetIPv4}}" +} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + vpc = { + subnet_id = linode_vpc_subnet.test.id + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/vpc_default_route_ipv4.gotf b/linode/linodeinterface/tmpl/vpc_default_route_ipv4.gotf new file mode 100644 index 000000000..f835452a5 --- /dev/null +++ b/linode/linodeinterface/tmpl/vpc_default_route_ipv4.gotf @@ -0,0 +1,40 @@ +{{ define "vpc_default_route_ipv4" }} + +resource "linode_vpc" "test" { + label = "{{ .Label }}" + region = "{{ .Region }}" +} + +resource "linode_vpc_subnet" "test" { + vpc_id = linode_vpc.test.id + label = "{{ .Label }}" + ipv4 = "{{ .SubnetIPv4 }}" +} + +resource "linode_instance" "test" { + label = "{{ .Label }}" + region = "{{ .Region }}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + vpc = { + subnet_id = linode_vpc_subnet.test.id + ipv4 = { + addresses = [ + { + address = "auto" + } + ] + } + } + + default_route = { + ipv4 = true + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/vpc_empty_ip_objects.gotf b/linode/linodeinterface/tmpl/vpc_empty_ip_objects.gotf new file mode 100644 index 000000000..ddce49565 --- /dev/null +++ b/linode/linodeinterface/tmpl/vpc_empty_ip_objects.gotf @@ -0,0 +1,32 @@ +{{ define "interface_vpc_empty_ip_objects" }} + +resource "linode_vpc" "test" { + label = "{{.Label}}-vpc" + region = "{{.Region}}" + description = "vpc for interface testing" +} + +resource "linode_vpc_subnet" "test" { + vpc_id = linode_vpc.test.id + label = "{{.Label}}-subnet" + ipv4 = "{{.SubnetIPv4}}" +} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + vpc = { + subnet_id = linode_vpc_subnet.test.id + ipv4 = {} + ipv6 = {} + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/vpc_with_ipv4.gotf b/linode/linodeinterface/tmpl/vpc_with_ipv4.gotf new file mode 100644 index 000000000..efd6deb9e --- /dev/null +++ b/linode/linodeinterface/tmpl/vpc_with_ipv4.gotf @@ -0,0 +1,38 @@ +{{ define "interface_vpc_with_ipv4" }} + +resource "linode_vpc" "test" { + label = "{{.Label}}-vpc" + region = "{{.Region}}" + description = "vpc for interface testing" +} + +resource "linode_vpc_subnet" "test" { + vpc_id = linode_vpc.test.id + label = "{{.Label}}-subnet" + ipv4 = "{{.SubnetIPv4}}" +} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + vpc = { + subnet_id = linode_vpc_subnet.test.id + ipv4 = { + addresses = [ + { + address = "{{.IPv4Address}}" + primary = true + } + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/nb/framework_models.go b/linode/nb/framework_models.go index 8417c29a7..6ae2fe104 100644 --- a/linode/nb/framework_models.go +++ b/linode/nb/framework_models.go @@ -154,6 +154,7 @@ func parseNBFirewalls( ctx context.Context, firewalls []linodego.Firewall, ) (*types.List, diag.Diagnostics) { + var diags diag.Diagnostics nbFirewalls := make([]FirewallModel, len(firewalls)) for i, fw := range firewalls { @@ -172,7 +173,7 @@ func parseNBFirewalls( nbFirewalls[i].Tags = tags if fw.Rules.Inbound != nil { - inBound, diags := firewall.FlattenFirewallRules(ctx, fw.Rules.Inbound, nbFirewalls[i].Inbound, false) + inBound := firewall.FlattenFirewallRules(ctx, fw.Rules.Inbound, nbFirewalls[i].Inbound, false, &diags) if diags.HasError() { return nil, diags } @@ -180,7 +181,7 @@ func parseNBFirewalls( } if fw.Rules.Outbound != nil { - outBound, diags := firewall.FlattenFirewallRules(ctx, fw.Rules.Outbound, nbFirewalls[i].Inbound, false) + outBound := firewall.FlattenFirewallRules(ctx, fw.Rules.Outbound, nbFirewalls[i].Inbound, false, &diags) if diags.HasError() { return nil, diags } diff --git a/linode/networkingip/datasource_test.go b/linode/networkingip/datasource_test.go deleted file mode 100644 index 1293422bc..000000000 --- a/linode/networkingip/datasource_test.go +++ /dev/null @@ -1,60 +0,0 @@ -//go:build integration || networkingip - -package networkingip_test - -import ( - "log" - "regexp" - "testing" - - "github.com/hashicorp/terraform-plugin-testing/helper/acctest" - "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/linode/terraform-provider-linode/v3/linode/acceptance" - "github.com/linode/terraform-provider-linode/v3/linode/networkingip/tmpl" -) - -var testRegion string - -func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}, "core") - if err != nil { - log.Fatal(err) - } - - testRegion = region -} - -func TestAccDataSourceNetworkingIP_basic(t *testing.T) { - t.Parallel() - - resourceName := "linode_instance.foobar" - dataResourceName := "data.linode_networking_ip.foobar" - - label := acctest.RandomWithPrefix("tf-test") - - resource.Test(t, resource.TestCase{ - PreCheck: func() { acceptance.PreCheck(t) }, - ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, - - Steps: []resource.TestStep{ - { - Config: tmpl.DataBasic(t, label, testRegion), - }, - { - Config: tmpl.DataBasic(t, label, testRegion), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrPair(dataResourceName, "address", resourceName, "ip_address"), - resource.TestCheckResourceAttrPair(dataResourceName, "linode_id", resourceName, "id"), - resource.TestCheckResourceAttrPair(dataResourceName, "region", resourceName, "region"), - resource.TestMatchResourceAttr(dataResourceName, "gateway", regexp.MustCompile(`\.1$`)), - resource.TestCheckResourceAttr(dataResourceName, "type", "ipv4"), - resource.TestCheckResourceAttr(dataResourceName, "public", "true"), - resource.TestCheckResourceAttrSet(dataResourceName, "reserved"), - resource.TestCheckResourceAttr(dataResourceName, "prefix", "24"), - resource.TestMatchResourceAttr(dataResourceName, "rdns", regexp.MustCompile(`.ip.linodeusercontent.com$`)), - resource.TestCheckNoResourceAttr(resourceName, "vpc_nat_1_1"), - ), - }, - }, - }) -} diff --git a/linode/networkingip/framework_datasource_model.go b/linode/networkingip/framework_datasource_model.go index 4ecd810b6..15e98a96f 100644 --- a/linode/networkingip/framework_datasource_model.go +++ b/linode/networkingip/framework_datasource_model.go @@ -4,23 +4,25 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" "github.com/linode/terraform-provider-linode/v3/linode/instancenetworking" ) type DataSourceModel struct { ID types.String `tfsdk:"id"` - Address types.String `tfsdk:"address"` - Gateway types.String `tfsdk:"gateway"` - SubnetMask types.String `tfsdk:"subnet_mask"` - Prefix types.Int64 `tfsdk:"prefix"` - Type types.String `tfsdk:"type"` - Public types.Bool `tfsdk:"public"` - RDNS types.String `tfsdk:"rdns"` - Reserved types.Bool `tfsdk:"reserved"` - LinodeID types.Int64 `tfsdk:"linode_id"` - Region types.String `tfsdk:"region"` - VPCNAT1To1 types.Object `tfsdk:"vpc_nat_1_1"` + Address types.String `tfsdk:"address"` + Gateway types.String `tfsdk:"gateway"` + SubnetMask types.String `tfsdk:"subnet_mask"` + Prefix types.Int64 `tfsdk:"prefix"` + Type types.String `tfsdk:"type"` + Public types.Bool `tfsdk:"public"` + RDNS types.String `tfsdk:"rdns"` + Reserved types.Bool `tfsdk:"reserved"` + LinodeID types.Int64 `tfsdk:"linode_id"` + InterfaceID types.Int64 `tfsdk:"interface_id"` + Region types.String `tfsdk:"region"` + VPCNAT1To1 types.Object `tfsdk:"vpc_nat_1_1"` } func (data *DataSourceModel) parseIP(ip *linodego.InstanceIP) diag.Diagnostics { @@ -33,6 +35,7 @@ func (data *DataSourceModel) parseIP(ip *linodego.InstanceIP) diag.Diagnostics { data.Public = types.BoolValue(ip.Public) data.RDNS = types.StringValue(ip.RDNS) data.LinodeID = types.Int64Value(int64(ip.LinodeID)) + data.InterfaceID = types.Int64PointerValue(helper.IntPtrToInt64Ptr(ip.InterfaceID)) data.Region = types.StringValue(ip.Region) data.Reserved = types.BoolValue(ip.Reserved) diff --git a/linode/networkingip/framework_datasource_schema.go b/linode/networkingip/framework_datasource_schema.go index 9e5cd29a7..45b32ec40 100644 --- a/linode/networkingip/framework_datasource_schema.go +++ b/linode/networkingip/framework_datasource_schema.go @@ -41,6 +41,10 @@ var frameworkDatasourceSchema = schema.Schema{ Description: "The ID of the Linode this address currently belongs to.", Computed: true, }, + "interface_id": schema.Int64Attribute{ + Description: "The ID of the interface this address is assigned to.", + Computed: true, + }, "region": schema.StringAttribute{ Description: "The Region this IP address resides in.", Computed: true, diff --git a/linode/networkingip/framework_datasource_test.go b/linode/networkingip/framework_datasource_test.go new file mode 100644 index 000000000..06f802281 --- /dev/null +++ b/linode/networkingip/framework_datasource_test.go @@ -0,0 +1,79 @@ +//go:build integration || networkingip + +package networkingip_test + +import ( + "log" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/compare" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/networkingip/tmpl" +) + +var testRegion string + +func init() { + region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}, "core") + if err != nil { + log.Fatal(err) + } + + testRegion = region +} + +func TestAccDataSourceNetworkingIP_basic(t *testing.T) { + t.Parallel() + + resourceName := "linode_instance.foobar" + dataResourceName := "data.linode_networking_ip.foobar" + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, label, testRegion), + }, + { + Config: tmpl.DataBasic(t, label, testRegion), + Check: resource.ComposeTestCheckFunc( + // statechecks can't compare int linode_id with string id without implementing a custom comparer. + // Keep this legacy check for now. + resource.TestCheckResourceAttrPair(dataResourceName, "linode_id", resourceName, "id"), + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.CompareValuePairs( + dataResourceName, + tfjsonpath.New("address"), + resourceName, + tfjsonpath.New("ipv4").AtSliceIndex(0), + compare.ValuesSame(), + ), + statecheck.CompareValuePairs(dataResourceName, tfjsonpath.New("region"), resourceName, tfjsonpath.New("region"), compare.ValuesSame()), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("gateway"), knownvalue.StringRegexp(regexp.MustCompile(`\.1$`))), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("type"), knownvalue.StringExact("ipv4")), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("public"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("reserved"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("prefix"), knownvalue.Int64Exact(24)), + statecheck.ExpectKnownValue( + dataResourceName, + tfjsonpath.New("rdns"), + knownvalue.StringRegexp(regexp.MustCompile(`.ip.linodeusercontent.com$`)), + ), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("vpc_nat_1_1"), knownvalue.Null()), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("interface_id"), knownvalue.Null()), + }, + }, + }, + }) +} diff --git a/linode/networkingips/famework_datasource_schema.go b/linode/networkingips/famework_datasource_schema.go index 06c4b69c5..3012ba3a8 100644 --- a/linode/networkingips/famework_datasource_schema.go +++ b/linode/networkingips/famework_datasource_schema.go @@ -68,6 +68,10 @@ var frameworkDatasourceSchema = schema.Schema{ Description: "The ID of the Linode this address currently belongs to.", Computed: true, }, + "interface_id": schema.Int64Attribute{ + Description: "The ID of the interface this address is assigned to.", + Computed: true, + }, "region": schema.StringAttribute{ Description: "The Region this IP address resides in.", Computed: true, diff --git a/linode/networkingips/framework_datasource_models.go b/linode/networkingips/framework_datasource_models.go index dc64986af..c095f1866 100644 --- a/linode/networkingips/framework_datasource_models.go +++ b/linode/networkingips/framework_datasource_models.go @@ -4,22 +4,24 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" "github.com/linode/terraform-provider-linode/v3/linode/instancenetworking" ) type IPAddressModel struct { - Address types.String `tfsdk:"address"` - Type types.String `tfsdk:"type"` - Region types.String `tfsdk:"region"` - RDNS types.String `tfsdk:"rdns"` - Prefix types.Int64 `tfsdk:"prefix"` - Gateway types.String `tfsdk:"gateway"` - SubnetMask types.String `tfsdk:"subnet_mask"` - Public types.Bool `tfsdk:"public"` - LinodeID types.Int64 `tfsdk:"linode_id"` - Reserved types.Bool `tfsdk:"reserved"` - VPCNAT1To1 types.Object `tfsdk:"vpc_nat_1_1"` + Address types.String `tfsdk:"address"` + Type types.String `tfsdk:"type"` + Region types.String `tfsdk:"region"` + RDNS types.String `tfsdk:"rdns"` + Prefix types.Int64 `tfsdk:"prefix"` + Gateway types.String `tfsdk:"gateway"` + SubnetMask types.String `tfsdk:"subnet_mask"` + Public types.Bool `tfsdk:"public"` + LinodeID types.Int64 `tfsdk:"linode_id"` + InterfaceID types.Int64 `tfsdk:"interface_id"` + Reserved types.Bool `tfsdk:"reserved"` + VPCNAT1To1 types.Object `tfsdk:"vpc_nat_1_1"` } func (m *IPAddressModel) ParseIP(ip linodego.InstanceIP) diag.Diagnostics { @@ -32,6 +34,7 @@ func (m *IPAddressModel) ParseIP(ip linodego.InstanceIP) diag.Diagnostics { m.SubnetMask = types.StringValue(ip.SubnetMask) m.Public = types.BoolValue(ip.Public) m.LinodeID = types.Int64Value(int64(ip.LinodeID)) + m.InterfaceID = types.Int64PointerValue(helper.IntPtrToInt64Ptr(ip.InterfaceID)) m.Reserved = types.BoolValue(ip.Reserved) vpcNAT1To1, d := instancenetworking.FlattenIPVPCNAT1To1(ip.VPCNAT1To1) diff --git a/linode/networkingips/datasource_test.go b/linode/networkingips/framework_datasource_test.go similarity index 63% rename from linode/networkingips/datasource_test.go rename to linode/networkingips/framework_datasource_test.go index 254d384ff..b4a7b6842 100644 --- a/linode/networkingips/datasource_test.go +++ b/linode/networkingips/framework_datasource_test.go @@ -10,7 +10,10 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" "github.com/linode/terraform-provider-linode/v3/linode/acceptance" "github.com/linode/terraform-provider-linode/v3/linode/networkingips/tmpl" ) @@ -97,18 +100,27 @@ func TestAccDataSourceNetworkingIP_filterReserved(t *testing.T) { Steps: []resource.TestStep{ { Config: tmpl.DataFilterReserved(t), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.#"), - resource.TestCheckResourceAttr(dataResourceName, "ip_addresses.0.reserved", "true"), - resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.0.address"), - resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.0.linode_id"), - resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.0.region"), - resource.TestMatchResourceAttr(dataResourceName, "ip_addresses.0.gateway", regexp.MustCompile(`\.1$`)), - resource.TestCheckResourceAttr(dataResourceName, "ip_addresses.0.type", "ipv4"), - resource.TestCheckResourceAttr(dataResourceName, "ip_addresses.0.public", "true"), - resource.TestCheckResourceAttr(dataResourceName, "ip_addresses.0.prefix", "24"), - resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.0.subnet_mask"), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("ip_addresses"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("reserved"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("address"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("interface_id"), knownvalue.Null()), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("region"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + dataResourceName, + tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("gateway"), + knownvalue.StringRegexp(regexp.MustCompile(`\.1$`)), + ), + statecheck.ExpectKnownValue( + dataResourceName, + tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("type"), + knownvalue.StringExact("ipv4"), + ), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("public"), knownvalue.Bool(true)), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("prefix"), knownvalue.Int64Exact(24)), + statecheck.ExpectKnownValue(dataResourceName, tfjsonpath.New("ip_addresses").AtSliceIndex(0).AtMapKey("subnet_mask"), knownvalue.NotNull()), + }, }, }, }) diff --git a/linode/vpcips/framework_models.go b/linode/vpcips/framework_models.go index 4e4b48d57..b82ccb53e 100644 --- a/linode/vpcips/framework_models.go +++ b/linode/vpcips/framework_models.go @@ -48,9 +48,15 @@ func (m *ModelVPCIP) FlattenVPCIP(ctx context.Context, vpcIp *linodego.VPCIP, pr m.NAT1To1 = helper.KeepOrUpdateStringPointer(m.NAT1To1, vpcIp.NAT1To1, preserveKnown) m.VPCID = helper.KeepOrUpdateInt64(m.VPCID, int64(vpcIp.VPCID), preserveKnown) m.SubnetID = helper.KeepOrUpdateInt64(m.SubnetID, int64(vpcIp.SubnetID), preserveKnown) - m.ConfigID = helper.KeepOrUpdateInt64(m.ConfigID, int64(vpcIp.ConfigID), preserveKnown) m.InterfaceID = helper.KeepOrUpdateInt64(m.InterfaceID, int64(vpcIp.InterfaceID), preserveKnown) + var newConfigID types.Int64 + if vpcIp.ConfigID == 0 { + newConfigID = types.Int64Null() + } else { + newConfigID = types.Int64Value(int64(vpcIp.ConfigID)) + } + m.ConfigID = helper.KeepOrUpdateValue(m.ConfigID, newConfigID, preserveKnown) m.IPv6Range = helper.KeepOrUpdateStringPointer(m.IPv6Range, vpcIp.IPv6Range, preserveKnown) m.IPv6IsPublic = helper.KeepOrUpdateBoolPointer(m.IPv6IsPublic, vpcIp.IPv6IsPublic, preserveKnown) diff --git a/linode/vpcsubnet/framework_models.go b/linode/vpcsubnet/framework_models.go index ddd1f68f8..0b8bd15ec 100644 --- a/linode/vpcsubnet/framework_models.go +++ b/linode/vpcsubnet/framework_models.go @@ -29,8 +29,9 @@ type BaseModel struct { func FlattenSubnetLinodeInterface(iface linodego.VPCSubnetLinodeInterface) (types.Object, diag.Diagnostics) { return types.ObjectValue(LinodeInterfaceObjectType.AttrTypes, map[string]attr.Value{ - "id": types.Int64Value(int64(iface.ID)), - "active": types.BoolValue(iface.Active), + "id": types.Int64Value(int64(iface.ID)), + "config_id": types.Int64PointerValue(helper.IntPtrToInt64Ptr(iface.ConfigID)), + "active": types.BoolValue(iface.Active), }) } diff --git a/linode/vpcsubnet/framework_schema_resource.go b/linode/vpcsubnet/framework_schema_resource.go index 50f85be78..99681ec75 100644 --- a/linode/vpcsubnet/framework_schema_resource.go +++ b/linode/vpcsubnet/framework_schema_resource.go @@ -14,8 +14,9 @@ import ( var LinodeInterfaceObjectType = types.ObjectType{ AttrTypes: map[string]attr.Type{ - "id": types.Int64Type, - "active": types.BoolType, + "id": types.Int64Type, + "config_id": types.Int64Type, + "active": types.BoolType, }, } @@ -100,7 +101,6 @@ var frameworkResourceSchema = schema.Schema{ Computed: true, CustomType: timetypes.RFC3339Type{}, }, - "linodes": schema.ListAttribute{ Computed: true, ElementType: LinodeObjectType, diff --git a/linode/vpcsubnets/datasource_test.go b/linode/vpcsubnets/framework_datasource_test.go similarity index 61% rename from linode/vpcsubnets/datasource_test.go rename to linode/vpcsubnets/framework_datasource_test.go index ad83d83fd..37cfb312e 100644 --- a/linode/vpcsubnets/datasource_test.go +++ b/linode/vpcsubnets/framework_datasource_test.go @@ -5,6 +5,7 @@ package vpcsubnets_test import ( "fmt" "log" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" @@ -47,16 +48,46 @@ func TestAccDataSourceVPCSubnets_basic_smoke(t *testing.T) { Config: tmpl.DataBasic(t, vpcLabel, testRegion, "10.0.0.0/24"), Check: resource.ComposeTestCheckFunc( acceptance.CheckResourceAttrGreaterThan(resourceName, "vpc_subnets.#", 0), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.id"), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.label"), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.ipv4"), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.created"), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.updated"), - - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.linodes.0.id"), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.linodes.0.interfaces.0.id"), - resource.TestCheckResourceAttr(resourceName, "vpc_subnets.0.linodes.0.interfaces.0.active", "false"), ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("label"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("ipv4"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("created"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("updated"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + resourceName, + tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("linodes").AtSliceIndex(0).AtMapKey("id"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + resourceName, + tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("linodes").AtSliceIndex(0).AtMapKey("interfaces").AtSliceIndex(0).AtMapKey("id"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + resourceName, + tfjsonpath.New("vpc_subnets"). + AtSliceIndex(0). + AtMapKey("linodes"). + AtSliceIndex(0). + AtMapKey("interfaces"). + AtSliceIndex(0). + AtMapKey("active"), + knownvalue.Bool(false), + ), + statecheck.ExpectKnownValue( + resourceName, + tfjsonpath.New("vpc_subnets"). + AtSliceIndex(0). + AtMapKey("linodes"). + AtSliceIndex(0). + AtMapKey("interfaces"). + AtSliceIndex(0). + AtMapKey("config_id"), + knownvalue.NotNull(), + ), + }, }, }, }) @@ -139,12 +170,18 @@ func TestAccDataSourceVPCSubnets_filterByLabel(t *testing.T) { Config: tmpl.DataFilterLabel(t, vpcLabel, testRegion, "10.0.0.0/24"), Check: resource.ComposeTestCheckFunc( acceptance.CheckResourceAttrGreaterThan(resourceName, "vpc_subnets.#", 0), - acceptance.CheckResourceAttrContains(resourceName, "vpc_subnets.0.label", "tf-test"), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.id"), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.ipv4"), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.created"), - resource.TestCheckResourceAttrSet(resourceName, "vpc_subnets.0.updated"), ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + resourceName, + tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("label"), + knownvalue.StringRegexp(regexp.MustCompile("tf-test")), + ), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("ipv4"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("created"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("vpc_subnets").AtSliceIndex(0).AtMapKey("updated"), knownvalue.NotNull()), + }, }, }, }) From 920e625bb72aa6009f169582675a5b34d15d88ab Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 16 Oct 2025 14:28:33 -0400 Subject: [PATCH 03/29] Added datasource/resource for Image Share Group --- .../producer_image_share_group.md | 47 ++ docs/resources/producer_image_share_group.md | 81 ++++ linode/framework_provider.go | 3 + linode/image/resource_test.go | 2 +- linode/images/datasource_test.go | 10 +- .../datasource_test.go | 43 ++ .../framework_datasource.go | 62 +++ .../framework_models.go | 89 ++++ .../framework_models_unit_test.go | 34 ++ .../framework_resource.go | 421 ++++++++++++++++++ .../framework_schema_datasource.go | 56 +++ .../framework_schema_resource.go | 85 ++++ .../producerimagesharegroup/resource_test.go | 172 +++++++ .../producerimagesharegroup/tmpl/basic.gotf | 8 + .../tmpl/data_basic.gotf | 9 + .../producerimagesharegroup/tmpl/template.go | 57 +++ .../producerimagesharegroup/tmpl/updates.gotf | 55 +++ 17 files changed, 1228 insertions(+), 6 deletions(-) create mode 100644 docs/data-sources/producer_image_share_group.md create mode 100644 docs/resources/producer_image_share_group.md create mode 100644 linode/producerimagesharegroup/datasource_test.go create mode 100644 linode/producerimagesharegroup/framework_datasource.go create mode 100644 linode/producerimagesharegroup/framework_models.go create mode 100644 linode/producerimagesharegroup/framework_models_unit_test.go create mode 100644 linode/producerimagesharegroup/framework_resource.go create mode 100644 linode/producerimagesharegroup/framework_schema_datasource.go create mode 100644 linode/producerimagesharegroup/framework_schema_resource.go create mode 100644 linode/producerimagesharegroup/resource_test.go create mode 100644 linode/producerimagesharegroup/tmpl/basic.gotf create mode 100644 linode/producerimagesharegroup/tmpl/data_basic.gotf create mode 100644 linode/producerimagesharegroup/tmpl/template.go create mode 100644 linode/producerimagesharegroup/tmpl/updates.gotf diff --git a/docs/data-sources/producer_image_share_group.md b/docs/data-sources/producer_image_share_group.md new file mode 100644 index 000000000..a5a7fb302 --- /dev/null +++ b/docs/data-sources/producer_image_share_group.md @@ -0,0 +1,47 @@ +--- +page_title: "Linode: linode_producer_image_share_group" +description: |- + Provides details about an Image Share Group created by a producer. +--- + +# Data Source: linode\producer\_image\_share\_group + +`linode_producer_image_share_group` provides details about an Image Share Group. +For more information, see the [Linode APIv4 docs](TODO). + + +## Example Usage + +The following example shows how the datasource might be used to obtain additional information about an Image Share Group. + +```hcl +data "linode_producer_image_share_group" "sg" { + id = 12345 +} +``` + +## Argument Reference + +* `id` - (Required) The ID of the Image Share Group. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `uuid` - The UUID of the Image Share Group. + +* `label` - The label of the Image Share Group. + +* `description` - The description of the Image Share Group. + +* `is_suspended` - Whether the Image Share Group is suspended. + +* `images_count` - The number of images in the Image Share Group. + +* `members_count` - The number of members in the Image Share Group. + +* `created` - The date and time the Image Share Group was created. + +* `updated` - The date and time the Image Share Group was last updated. + +* `expiry` - The date and time the Image Share Group will expire. diff --git a/docs/resources/producer_image_share_group.md b/docs/resources/producer_image_share_group.md new file mode 100644 index 000000000..3f66965bd --- /dev/null +++ b/docs/resources/producer_image_share_group.md @@ -0,0 +1,81 @@ +--- +page_title: "Linode: linode_producer_image_share_group" +description: |- + Manages an Image Share Group. +--- + +# linode\producer\_image\_share\_group + +Manages an Image Share Group. +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +Create an Image Share Group without any Images: + +```terraform +resource "linode_producer_image_share_group" "test-empty" { + label = "my-image-share-group" + description = "My description." +} +``` + +Create an Image Share Group with one Image: + +```terraform +resource "linode_producer_image_share_group" "test-images" { + label = "my-image-share-group" + description = "My description." + images = [ + { + id = "private/12345" + label = "my-image" + description = "My image description." + }, + ] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `label` - (Required) The label of the Image Share Group. + +* `description` - (Optional) The description of the Image Share Group + +* [`images`](#images) - (Optional) A list of Images to include in the Image Share Group. + +## Attributes Reference + +In addition to all the arguments above, the following attributes are exported. + +* `id` - The ID of the Image Share Group. + +* `uuid` - The UUID of the Image Share Group. + +* `label` - The label of the Image Share Group. + +* `description` - The description of the Image Share Group. + +* `is_suspended` - Whether the Image Share Group is suspended. + +* `images_count` - The number of images in the Image Share Group. + +* `members_count` - The number of members in the Image Share Group. + +* `created` - The date and time the Image Share Group was created. + +* `updated` - The date and time the Image Share Group was last updated. + +* `expiry` - The date and time the Image Share Group will expire. + +### Images + +Represents a single Image shared in an Image Share Group. + +* `id` - (Required) The ID of the Image to share. This must be in the format `private/`. + +* `label` - (Optional) The label of the Image Share. + +* `description` - (Optional) The description of the Image Share. diff --git a/linode/framework_provider.go b/linode/framework_provider.go index 972aaa23c..7150c098c 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -75,6 +75,7 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/placementgroup" "github.com/linode/terraform-provider-linode/v3/linode/placementgroupassignment" "github.com/linode/terraform-provider-linode/v3/linode/placementgroups" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroup" "github.com/linode/terraform-provider-linode/v3/linode/profile" "github.com/linode/terraform-provider-linode/v3/linode/rdns" "github.com/linode/terraform-provider-linode/v3/linode/region" @@ -252,6 +253,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res networkingipassignment.NewResource, obj.NewResource, databasemysqlv2.NewResource, + producerimagesharegroup.NewResource, } } @@ -332,5 +334,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource objendpoints.NewDataSource, objquota.NewDataSource, objquotas.NewDataSource, + producerimagesharegroup.NewDataSource, } } diff --git a/linode/image/resource_test.go b/linode/image/resource_test.go index 2af8b2d47..bee199b44 100644 --- a/linode/image/resource_test.go +++ b/linode/image/resource_test.go @@ -70,7 +70,7 @@ func init() { return !ok || !isDisallowed }) - testRegion = testRegions[0] + testRegion = testRegions[1] } func sweep(prefix string) error { diff --git a/linode/images/datasource_test.go b/linode/images/datasource_test.go index b070e41f8..26b131453 100644 --- a/linode/images/datasource_test.go +++ b/linode/images/datasource_test.go @@ -44,7 +44,7 @@ func TestAccDataSourceImages_basic_smoke(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "images.0.is_shared", "false"), resource.TestCheckResourceAttr(resourceName, "images.0.image_sharing.shared_with.sharegroup_count", "0"), resource.TestCheckResourceAttrSet(resourceName, "images.0.image_sharing.shared_with.sharegroup_list_url"), - resource.TestCheckNoResourceAttr(resourceName, "image_sharing.shared_by"), + resource.TestCheckNoResourceAttr(resourceName, "images.0.image_sharing.shared_by"), resource.TestCheckResourceAttr(resourceName, "images.0.type", "manual"), acceptance.CheckListContains(resourceName, "images.0.tags", "test"), resource.TestCheckResourceAttrSet(resourceName, "images.0.created"), @@ -57,10 +57,10 @@ func TestAccDataSourceImages_basic_smoke(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "images.1.label", imageName), resource.TestCheckResourceAttr(resourceName, "images.1.description", "descriptive text"), resource.TestCheckResourceAttr(resourceName, "images.1.is_public", "false"), - resource.TestCheckResourceAttr(resourceName, "images.0.is_shared", "false"), - resource.TestCheckResourceAttr(resourceName, "images.0.image_sharing.shared_with.sharegroup_count", "0"), - resource.TestCheckResourceAttrSet(resourceName, "images.0.image_sharing.shared_with.sharegroup_list_url"), - resource.TestCheckNoResourceAttr(resourceName, "image_sharing.shared_by"), + resource.TestCheckResourceAttr(resourceName, "images.1.is_shared", "false"), + resource.TestCheckResourceAttr(resourceName, "images.1.image_sharing.shared_with.sharegroup_count", "0"), + resource.TestCheckResourceAttrSet(resourceName, "images.1.image_sharing.shared_with.sharegroup_list_url"), + resource.TestCheckNoResourceAttr(resourceName, "images.1.image_sharing.shared_by"), resource.TestCheckResourceAttr(resourceName, "images.1.type", "manual"), acceptance.CheckListContains(resourceName, "images.1.tags", "test"), resource.TestCheckResourceAttrSet(resourceName, "images.1.created"), diff --git a/linode/producerimagesharegroup/datasource_test.go b/linode/producerimagesharegroup/datasource_test.go new file mode 100644 index 000000000..b049a453d --- /dev/null +++ b/linode/producerimagesharegroup/datasource_test.go @@ -0,0 +1,43 @@ +//go:build integration || producerimagesharegroup + +package producerimagesharegroup_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroup/tmpl" +) + +func TestAccDataSourceImageShareGroup_basic(t *testing.T) { + t.Parallel() + + resourceName := "data.linode_producer_image_share_group.foobar" + + label := acctest.RandomWithPrefix("tf-test") + description := "A cool description." + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, label, description), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + resource.TestCheckResourceAttr(resourceName, "label", label), + resource.TestCheckResourceAttr(resourceName, "description", description), + resource.TestCheckResourceAttr(resourceName, "is_suspended", "false"), + resource.TestCheckResourceAttr(resourceName, "images_count", "0"), + resource.TestCheckResourceAttr(resourceName, "members_count", "0"), + resource.TestCheckResourceAttrSet(resourceName, "created"), + resource.TestCheckNoResourceAttr(resourceName, "updated"), + resource.TestCheckNoResourceAttr(resourceName, "expiry"), + ), + }, + }, + }) +} diff --git a/linode/producerimagesharegroup/framework_datasource.go b/linode/producerimagesharegroup/framework_datasource.go new file mode 100644 index 000000000..ae690eca8 --- /dev/null +++ b/linode/producerimagesharegroup/framework_datasource.go @@ -0,0 +1,62 @@ +package producerimagesharegroup + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_producer_image_share_group", + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (d *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+d.Config.Name) + + client := d.Meta.Client + + var data DataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + id := helper.FrameworkSafeInt64ToInt( + data.ID.ValueInt64(), + &resp.Diagnostics, + ) + if resp.Diagnostics.HasError() { + return + } + + tflog.Trace(ctx, "client.GetImageShareGroup(...)") + sg, err := client.GetImageShareGroup(ctx, id) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Unable to get Image Share Group %v", id), err.Error(), + ) + return + } + + data.ParseImageShareGroup(sg) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/linode/producerimagesharegroup/framework_models.go b/linode/producerimagesharegroup/framework_models.go new file mode 100644 index 000000000..d88a43962 --- /dev/null +++ b/linode/producerimagesharegroup/framework_models.go @@ -0,0 +1,89 @@ +package producerimagesharegroup + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type ResourceModel struct { + ID types.Int64 `tfsdk:"id"` + UUID types.String `tfsdk:"uuid"` + Label types.String `tfsdk:"label"` + Description types.String `tfsdk:"description"` + IsSuspended types.Bool `tfsdk:"is_suspended"` + ImagesCount types.Int64 `tfsdk:"images_count"` + MembersCount types.Int64 `tfsdk:"members_count"` + Created timetypes.RFC3339 `tfsdk:"created"` + Updated timetypes.RFC3339 `tfsdk:"updated"` + Expiry timetypes.RFC3339 `tfsdk:"expiry"` + Images types.List `tfsdk:"images"` +} + +type ImageShareAttributesModel struct { + ID types.String `tfsdk:"id"` + Label types.String `tfsdk:"label"` + Description types.String `tfsdk:"description"` +} + +func (data *ResourceModel) FlattenImageShareGroup( + imageShareGroup *linodego.ProducerImageShareGroup, + preserveKnown bool, +) { + data.ID = helper.KeepOrUpdateInt64(data.ID, int64(imageShareGroup.ID), preserveKnown) + data.UUID = helper.KeepOrUpdateString(data.UUID, imageShareGroup.UUID, preserveKnown) + data.Label = helper.KeepOrUpdateString(data.Label, imageShareGroup.Label, preserveKnown) + data.Description = helper.KeepOrUpdateString( + data.Description, imageShareGroup.Description, preserveKnown, + ) + data.IsSuspended = helper.KeepOrUpdateBool(data.IsSuspended, imageShareGroup.IsSuspended, preserveKnown) + data.ImagesCount = helper.KeepOrUpdateInt64(data.ImagesCount, int64(imageShareGroup.ImagesCount), preserveKnown) + data.MembersCount = helper.KeepOrUpdateInt64(data.MembersCount, int64(imageShareGroup.MembersCount), preserveKnown) + data.Created = helper.KeepOrUpdateValue( + data.Created, timetypes.NewRFC3339TimePointerValue(imageShareGroup.Created), preserveKnown, + ) + data.Updated = helper.KeepOrUpdateValue( + data.Updated, timetypes.NewRFC3339TimePointerValue(imageShareGroup.Updated), preserveKnown, + ) + data.Expiry = helper.KeepOrUpdateValue( + data.Expiry, timetypes.NewRFC3339TimePointerValue(imageShareGroup.Expiry), preserveKnown, + ) + + // Images must persist in state across CRUD operations but is not returned by the API. It will be maintained + // manually as a part of Create, Update, and Read, so we only need to set it here if it is null so that it is + // properly typed. + if data.Images.IsNull() { + data.Images = types.ListNull(imageShareGroupImage.Type()) + } +} + +type DataSourceModel struct { + ID types.Int64 `tfsdk:"id"` + UUID types.String `tfsdk:"uuid"` + Label types.String `tfsdk:"label"` + Description types.String `tfsdk:"description"` + IsSuspended types.Bool `tfsdk:"is_suspended"` + ImagesCount types.Int64 `tfsdk:"images_count"` + MembersCount types.Int64 `tfsdk:"members_count"` + Created timetypes.RFC3339 `tfsdk:"created"` + Updated timetypes.RFC3339 `tfsdk:"updated"` + Expiry timetypes.RFC3339 `tfsdk:"expiry"` +} + +func (data *DataSourceModel) ParseImageShareGroup(isg *linodego.ProducerImageShareGroup, +) diag.Diagnostics { + data.ID = types.Int64Value(int64(isg.ID)) + data.UUID = types.StringValue(isg.UUID) + data.Label = types.StringValue(isg.Label) + data.Description = types.StringValue(isg.Description) + data.IsSuspended = types.BoolValue(isg.IsSuspended) + data.ImagesCount = types.Int64Value(int64(isg.ImagesCount)) + data.MembersCount = types.Int64Value(int64(isg.MembersCount)) + data.Created = timetypes.NewRFC3339TimePointerValue(isg.Created) + data.Updated = timetypes.NewRFC3339TimePointerValue(isg.Updated) + data.Expiry = timetypes.NewRFC3339TimePointerValue(isg.Expiry) + + return nil +} diff --git a/linode/producerimagesharegroup/framework_models_unit_test.go b/linode/producerimagesharegroup/framework_models_unit_test.go new file mode 100644 index 000000000..19bbfefd0 --- /dev/null +++ b/linode/producerimagesharegroup/framework_models_unit_test.go @@ -0,0 +1,34 @@ +//go:build unit + +package producerimagesharegroup + +import ( + "testing" + + "github.com/linode/linodego" + "github.com/stretchr/testify/require" +) + +func TestParseImageShareGroup(t *testing.T) { + sg := linodego.ProducerImageShareGroup{ + ID: 123, + UUID: "b1966cda-4083-4414-a140-45b78d48ec27", + Label: "my-label", + Description: "My description.", + IsSuspended: false, + ImagesCount: 1, + MembersCount: 1, + } + + data := &DataSourceModel{} + + data.ParseImageShareGroup(&sg) + + require.Equal(t, int64(123), data.ID.ValueInt64()) + require.Equal(t, "b1966cda-4083-4414-a140-45b78d48ec27", data.UUID.ValueString()) + require.Equal(t, "my-label", data.Label.ValueString()) + require.Equal(t, "My description.", data.Description.ValueString()) + require.Equal(t, false, data.IsSuspended.ValueBool()) + require.Equal(t, int64(1), data.ImagesCount.ValueInt64()) + require.Equal(t, int64(1), data.MembersCount.ValueInt64()) +} diff --git a/linode/producerimagesharegroup/framework_resource.go b/linode/producerimagesharegroup/framework_resource.go new file mode 100644 index 000000000..32221ecfb --- /dev/null +++ b/linode/producerimagesharegroup/framework_resource.go @@ -0,0 +1,421 @@ +package producerimagesharegroup + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "linode_producer_image_share_group", + IDType: types.Int64Type, + Schema: &frameworkResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + tflog.Debug(ctx, "Create "+r.Config.Name) + + var plan ResourceModel + client := r.Meta.Client + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Prepare list of images to include in the Image Share Group + var imageShares []linodego.ImageShareGroupImage + + if !plan.Images.IsNull() && !plan.Images.IsUnknown() { + var imageModels []ImageShareAttributesModel + resp.Diagnostics.Append(plan.Images.ElementsAs(ctx, &imageModels, false)...) + if resp.Diagnostics.HasError() { + return + } + + for _, img := range imageModels { + imageShares = append(imageShares, linodego.ImageShareGroupImage{ + ID: img.ID.ValueString(), + Label: img.Label.ValueStringPointer(), + Description: img.Description.ValueStringPointer(), + }) + } + } + + createOpts := linodego.ImageShareGroupCreateOptions{ + Label: plan.Label.ValueString(), + Description: plan.Description.ValueStringPointer(), + Images: imageShares, + } + + tflog.Debug(ctx, "client.CreateImageShareGroup(...)", map[string]any{ + "options": createOpts, + }) + sg, err := client.CreateImageShareGroup(ctx, createOpts) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create Image Share Group.", + err.Error(), + ) + return + } + + plan.FlattenImageShareGroup(sg, true) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + tflog.Debug(ctx, "Read "+r.Config.Name) + + client := r.Meta.Client + + var state ResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + imageShareGroupID := helper.FrameworkSafeInt64ToInt(state.ID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve the main Image Share Group details + sg, err := client.GetImageShareGroup(ctx, imageShareGroupID) + if err != nil { + resp.Diagnostics.AddError( + "Failed to read Image Share Group.", + err.Error(), + ) + return + } + + // Retrieve the list of images in this Image Share Group + imagesResp, err := client.ImageShareGroupListImageShareEntries(ctx, imageShareGroupID, nil) + if err != nil { + resp.Diagnostics.AddError( + "Failed to read images for Image Share Group.", + err.Error(), + ) + return + } + + // Convert images into simplified image share structs + var imageShares []linodego.ImageShareGroupImage + + for _, img := range imagesResp { + sourceID := "" + if img.ImageSharing.SharedBy != nil && img.ImageSharing.SharedBy.SourceImageID != nil { + sourceID = *img.ImageSharing.SharedBy.SourceImageID + } + + imageShares = append(imageShares, linodego.ImageShareGroupImage{ + ID: sourceID, + Label: linodego.Pointer(img.Label), + Description: linodego.Pointer(img.Description), + }) + } + + // Flatten the main share group + state.FlattenImageShareGroup(sg, true) + if resp.Diagnostics.HasError() { + return + } + + // Update the images list in the state + var imageModels []ImageShareAttributesModel + for _, img := range imageShares { + imageModels = append(imageModels, ImageShareAttributesModel{ + ID: types.StringValue(img.ID), + Label: types.StringPointerValue(img.Label), + Description: types.StringPointerValue(img.Description), + }) + } + + listVal, diag := types.ListValueFrom(ctx, imageShareGroupImage.Type(), imageModels) + resp.Diagnostics.Append(diag...) + if resp.Diagnostics.HasError() { + return + } + + state.Images = listVal + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + tflog.Debug(ctx, "Update "+r.Config.Name) + + client := r.Meta.Client + + var plan ResourceModel + var state ResourceModel + + // Get current plan and state + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + imageShareGroupID := helper.FrameworkSafeInt64ToInt(state.ID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Refresh remote state to ensure accuracy + sg, err := client.GetImageShareGroup(ctx, imageShareGroupID) + if err != nil { + resp.Diagnostics.AddError( + "Failed to read Image Share Group.", + err.Error(), + ) + return + } + + imagesResp, err := client.ImageShareGroupListImageShareEntries(ctx, imageShareGroupID, nil) + if err != nil { + resp.Diagnostics.AddError( + "Failed to read images for Image Share Group.", + err.Error(), + ) + return + } + + var imageShares []imageData + + for _, img := range imagesResp { + sourceID := "" + if img.ImageSharing.SharedBy != nil && img.ImageSharing.SharedBy.SourceImageID != nil { + sourceID = *img.ImageSharing.SharedBy.SourceImageID + } + + imageShares = append(imageShares, imageData{ + PrivateID: sourceID, + SharedID: img.ID, + Label: linodego.Pointer(img.Label), + Description: linodego.Pointer(img.Description), + }) + } + + // Build desired vs. actual sets + var planImages []ImageShareAttributesModel + if !plan.Images.IsNull() && !plan.Images.IsUnknown() { + resp.Diagnostics.Append(plan.Images.ElementsAs(ctx, &planImages, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + // Build lookup maps + planMap := make(map[string]ImageShareAttributesModel) + for _, img := range planImages { + planMap[img.ID.ValueString()] = img + } + + remoteMap := make(map[string]imageData) + for _, img := range imageShares { + remoteMap[img.PrivateID] = img + } + + var toAdd []linodego.ImageShareGroupImage + var toUpdate []struct { + id string + opts linodego.ImageShareGroupUpdateImageOptions + } + var toRemove []string + + // Detect creates and updates + for privateID, planImg := range planMap { + remote, exists := remoteMap[privateID] + if !exists { + // Not found remotely, so add + toAdd = append(toAdd, linodego.ImageShareGroupImage{ + ID: privateID, + Label: planImg.Label.ValueStringPointer(), + Description: planImg.Description.ValueStringPointer(), + }) + } else { + // Found remotely, check for changes + labelChanged := planImg.Label.ValueString() != helper.StringValue(remote.Label) + descChanged := planImg.Description.ValueString() != helper.StringValue(remote.Description) + + if labelChanged || descChanged { + opts := linodego.ImageShareGroupUpdateImageOptions{} + if labelChanged { + opts.Label = planImg.Label.ValueStringPointer() + } + if descChanged { + opts.Description = planImg.Description.ValueStringPointer() + } + // Use SharedID for the update call + toUpdate = append(toUpdate, struct { + id string + opts linodego.ImageShareGroupUpdateImageOptions + }{ + id: remote.SharedID, + opts: opts, + }) + } + } + } + + // Detect removals + for _, remote := range remoteMap { + if _, exists := planMap[remote.PrivateID]; !exists { + toRemove = append(toRemove, remote.SharedID) + } + } + + // Apply changes + if len(toAdd) > 0 { + opts := linodego.ImageShareGroupAddImagesOptions{Images: toAdd} + if _, err := client.ImageShareGroupAddImages(ctx, imageShareGroupID, opts); err != nil { + resp.Diagnostics.AddError("Failed to add images", err.Error()) + return + } + } + + for _, u := range toUpdate { + if _, err := client.ImageShareGroupUpdateImageShareEntry(ctx, imageShareGroupID, u.id, u.opts); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to update image %s", u.id), err.Error()) + return + } + } + + for _, id := range toRemove { + if err := client.ImageShareGroupRemoveImage(ctx, imageShareGroupID, id); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove image %s", id), err.Error()) + return + } + } + + // Update Image Share Group + labelChanged := !plan.Label.Equal(state.Label) + descChanged := !plan.Description.Equal(state.Description) + if labelChanged || descChanged { + updateOpts := linodego.ImageShareGroupUpdateOptions{ + Label: plan.Label.ValueStringPointer(), + Description: plan.Description.ValueStringPointer(), + } + if _, err := client.UpdateImageShareGroup(ctx, imageShareGroupID, updateOpts); err != nil { + resp.Diagnostics.AddError("Failed to update share group", err.Error()) + return + } + } + + // Refresh and persist final state + sg, err = client.GetImageShareGroup(ctx, imageShareGroupID) + if err != nil { + resp.Diagnostics.AddError("Failed to re-fetch share group", err.Error()) + return + } + + finalImages, err := client.ImageShareGroupListImageShareEntries(ctx, imageShareGroupID, nil) + if err != nil { + resp.Diagnostics.AddError("Failed to re-fetch share group images", err.Error()) + return + } + + // Flatten and update state + state.FlattenImageShareGroup(sg, false) + if resp.Diagnostics.HasError() { + return + } + + // Convert images into framework list + var imageModels []ImageShareAttributesModel + for _, img := range finalImages { + sourceID := "" + if img.ImageSharing.SharedBy != nil && img.ImageSharing.SharedBy.SourceImageID != nil { + sourceID = *img.ImageSharing.SharedBy.SourceImageID + } + + imageModels = append(imageModels, ImageShareAttributesModel{ + ID: types.StringValue(sourceID), + Label: stringToPointerValue(img.Label), + Description: stringToPointerValue(img.Description), + }) + } + + listVal, diag := types.ListValueFrom(ctx, imageShareGroupImage.Type(), imageModels) + resp.Diagnostics.Append(diag...) + if resp.Diagnostics.HasError() { + return + } + state.Images = listVal + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + tflog.Debug(ctx, "Delete "+r.Config.Name) + + var state ResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + imageShareGroupID := helper.FrameworkSafeInt64ToInt(state.ID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + err := client.DeleteImageShareGroup(ctx, imageShareGroupID) + if err != nil { + resp.Diagnostics.AddError("Failed to Delete Image Share Group", err.Error()) + return + } +} + +// This is a custom struct to store a subset of im_ImageShare row data for use in detecting/running updates and deletions +// for the Images in an Image Share Group +type imageData struct { + PrivateID string // original private ID from API + SharedID string // source_image_id from ImageSharing.SharedBy.SourceImageID + Label *string // optional label + Description *string // optional description +} + +func stringToPointerValue(s string) types.String { + if s == "" { + return types.StringNull() + } + return types.StringValue(s) +} diff --git a/linode/producerimagesharegroup/framework_schema_datasource.go b/linode/producerimagesharegroup/framework_schema_datasource.go new file mode 100644 index 000000000..f271e3fd6 --- /dev/null +++ b/linode/producerimagesharegroup/framework_schema_datasource.go @@ -0,0 +1,56 @@ +package producerimagesharegroup + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +var Attributes = map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Description: "The ID of the Image Share Group.", + Required: true, + }, + "uuid": schema.StringAttribute{ + Description: "The UUID of the Image Share Group.", + Computed: true, + }, + "label": schema.StringAttribute{ + Description: "The label of the Image Share Group.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "The label of the Image Share Group.", + Computed: true, + }, + "is_suspended": schema.BoolAttribute{ + Description: "Whether or not the Image Share Group is suspended.", + Computed: true, + }, + "images_count": schema.Int64Attribute{ + Description: "The number of images in the Image Share Group.", + Computed: true, + }, + "members_count": schema.Int64Attribute{ + Description: "The number of members in the Image Share Group.", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "When this Image Share Group was created.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "updated": schema.StringAttribute{ + Description: "When this Image Share Group was last updated.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "expiry": schema.StringAttribute{ + Description: "When this Image Share Group will expire.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, +} + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: Attributes, +} diff --git a/linode/producerimagesharegroup/framework_schema_resource.go b/linode/producerimagesharegroup/framework_schema_resource.go new file mode 100644 index 000000000..ed289a6b6 --- /dev/null +++ b/linode/producerimagesharegroup/framework_schema_resource.go @@ -0,0 +1,85 @@ +package producerimagesharegroup + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" +) + +var imageShareGroupImage = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of an image to share in this Image Share Group.", + Required: true, + }, + "label": schema.StringAttribute{ + Description: "The label for the im_ImageShare row associated with this shared image.", + Optional: true, + }, + "description": schema.StringAttribute{ + Description: "The description for the im_ImageShare row associated with this shared image.", + Optional: true, + }, + }, +} + +var frameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Description: "The ID of the Image Share Group.", + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "uuid": schema.StringAttribute{ + Description: "The UUID of the Image Share Group.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "label": schema.StringAttribute{ + Description: "The label of the Image Share Group.", + Required: true, + }, + "description": schema.StringAttribute{ + Description: "The label of the Image Share Group.", + Optional: true, + }, + "is_suspended": schema.BoolAttribute{ + Description: "Whether or not the Image Share Group is suspended.", + Computed: true, + }, + "images_count": schema.Int64Attribute{ + Description: "The number of images in the Image Share Group.", + Computed: true, + }, + "members_count": schema.Int64Attribute{ + Description: "The number of members in the Image Share Group.", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "When this Image Share Group was created.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "updated": schema.StringAttribute{ + Description: "When this Image Share Group was last updated.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "expiry": schema.StringAttribute{ + Description: "When this Image Share Group will expire.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "images": schema.ListNestedAttribute{ + Description: "The images to be shared using this Image Share Group.", + Optional: true, + NestedObject: imageShareGroupImage, + }, + }, +} diff --git a/linode/producerimagesharegroup/resource_test.go b/linode/producerimagesharegroup/resource_test.go new file mode 100644 index 000000000..7c6ece5a2 --- /dev/null +++ b/linode/producerimagesharegroup/resource_test.go @@ -0,0 +1,172 @@ +//go:build integration || producerimagesharegroup + +package producerimagesharegroup_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroup/tmpl" +) + +func TestAccResourceImageShareGroup_basic(t *testing.T) { + t.Parallel() + + resourceName := "linode_producer_image_share_group.foobar" + label := acctest.RandomWithPrefix("tf-test") + description := "A cool description." + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.Basic(t, label, description), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + resource.TestCheckResourceAttr(resourceName, "label", label), + resource.TestCheckResourceAttr(resourceName, "description", description), + resource.TestCheckResourceAttr(resourceName, "is_suspended", "false"), + resource.TestCheckResourceAttr(resourceName, "images_count", "0"), + resource.TestCheckResourceAttr(resourceName, "members_count", "0"), + resource.TestCheckResourceAttrSet(resourceName, "created"), + resource.TestCheckNoResourceAttr(resourceName, "updated"), + resource.TestCheckNoResourceAttr(resourceName, "expiry"), + ), + }, + }, + }) +} + +func TestAccResourceImageShareGroup_updates(t *testing.T) { + t.Parallel() + + resourceName := "linode_producer_image_share_group.foobar" + label := acctest.RandomWithPrefix("tf-test") + testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"Linodes"}, "core") + if err != nil { + t.Fatalf("failed to get test region: %s", err) + } + imageLabel1 := "image" + acctest.RandomWithPrefix("tf-test") + imageLabel2 := "image" + acctest.RandomWithPrefix("tf-test") + isgLabel := "sharegroup" + acctest.RandomWithPrefix("tf-test") + isgDescription := "A cool description." + isgLabelUpdated := isgLabel + "-updated" + isgDescriptionUpdated := isgDescription + " updated" + + imagesStep2 := []tmpl.ShareGroupImageTemplate{ + { + ID: "${linode_image.foobar.id}", + Label: "Share-Image-1", + Description: "Share Image 1 Description", + }, + } + + imagesStep3 := []tmpl.ShareGroupImageTemplate{ + { + ID: "${linode_image.foobar.id}", + Label: "Share-Image-1-updated", + Description: "Share Image 1 Description updated", + }, + { + ID: "${linode_image.barfoo.id}", + Label: "Share-Image-2", + Description: "Share Image 2 Description", + }, + } + + imagesStep4 := []tmpl.ShareGroupImageTemplate{ + { + ID: "${linode_image.foobar.id}", + Label: "Share-Image-1-updated", + Description: "Share Image 1 Description updated", + }, + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Step 1: Create empty share group + { + Config: tmpl.Updates(t, label, testRegion, imageLabel1, imageLabel2, isgLabel, isgDescription, nil), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + resource.TestCheckResourceAttr(resourceName, "label", isgLabel), + resource.TestCheckResourceAttr(resourceName, "description", isgDescription), + resource.TestCheckResourceAttr(resourceName, "is_suspended", "false"), + resource.TestCheckResourceAttr(resourceName, "images_count", "0"), + resource.TestCheckResourceAttr(resourceName, "members_count", "0"), + ), + }, + // Step 2: Add first image + { + Config: tmpl.Updates(t, label, testRegion, imageLabel1, imageLabel2, isgLabel, isgDescription, imagesStep2), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + resource.TestCheckResourceAttr(resourceName, "label", isgLabel), + resource.TestCheckResourceAttr(resourceName, "description", isgDescription), + resource.TestCheckResourceAttr(resourceName, "is_suspended", "false"), + resource.TestCheckResourceAttr(resourceName, "images_count", "1"), + resource.TestCheckResourceAttr(resourceName, "members_count", "0"), + resource.TestCheckResourceAttrSet(resourceName, "images.0.id"), + resource.TestCheckResourceAttr(resourceName, "images.0.label", "Share-Image-1"), + resource.TestCheckResourceAttr(resourceName, "images.0.description", "Share Image 1 Description"), + ), + }, + // Step 3: Add second image and update first image + { + Config: tmpl.Updates(t, label, testRegion, imageLabel1, imageLabel2, isgLabel, isgDescription, imagesStep3), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + resource.TestCheckResourceAttr(resourceName, "label", isgLabel), + resource.TestCheckResourceAttr(resourceName, "description", isgDescription), + resource.TestCheckResourceAttr(resourceName, "is_suspended", "false"), + resource.TestCheckResourceAttr(resourceName, "images_count", "2"), + resource.TestCheckResourceAttr(resourceName, "members_count", "0"), + resource.TestCheckResourceAttrSet(resourceName, "images.0.id"), + resource.TestCheckResourceAttr(resourceName, "images.0.label", "Share-Image-1-updated"), + resource.TestCheckResourceAttr(resourceName, "images.0.description", "Share Image 1 Description updated"), + resource.TestCheckResourceAttrSet(resourceName, "images.1.id"), + resource.TestCheckResourceAttr(resourceName, "images.1.label", "Share-Image-2"), + resource.TestCheckResourceAttr(resourceName, "images.1.description", "Share Image 2 Description"), + ), + }, + // Step 4: Update the Share Group and remove the second image + { + Config: tmpl.Updates(t, label, testRegion, imageLabel1, imageLabel2, isgLabelUpdated, isgDescriptionUpdated, imagesStep4), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + resource.TestCheckResourceAttr(resourceName, "label", isgLabel+"-updated"), + resource.TestCheckResourceAttr(resourceName, "description", isgDescription+" updated"), + resource.TestCheckResourceAttr(resourceName, "is_suspended", "false"), + resource.TestCheckResourceAttr(resourceName, "images_count", "1"), + resource.TestCheckResourceAttr(resourceName, "members_count", "0"), + resource.TestCheckResourceAttrSet(resourceName, "images.0.id"), + resource.TestCheckResourceAttr(resourceName, "images.0.label", "Share-Image-1-updated"), + resource.TestCheckResourceAttr(resourceName, "images.0.description", "Share Image 1 Description updated"), + ), + }, + // Step 5: Remove the first image + { + Config: tmpl.Updates(t, label, testRegion, imageLabel1, imageLabel2, isgLabelUpdated, isgDescriptionUpdated, nil), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + resource.TestCheckResourceAttr(resourceName, "label", isgLabel+"-updated"), + resource.TestCheckResourceAttr(resourceName, "description", isgDescription+" updated"), + resource.TestCheckResourceAttr(resourceName, "is_suspended", "false"), + resource.TestCheckResourceAttr(resourceName, "images_count", "0"), + resource.TestCheckResourceAttr(resourceName, "members_count", "0"), + ), + }, + }, + }) +} diff --git a/linode/producerimagesharegroup/tmpl/basic.gotf b/linode/producerimagesharegroup/tmpl/basic.gotf new file mode 100644 index 000000000..2881d928b --- /dev/null +++ b/linode/producerimagesharegroup/tmpl/basic.gotf @@ -0,0 +1,8 @@ +{{ define "producer_image_share_group_basic" }} + +resource "linode_producer_image_share_group" "foobar" { + label = "{{.Label}}" + description = "{{.Description}}" +} + +{{ end }} \ No newline at end of file diff --git a/linode/producerimagesharegroup/tmpl/data_basic.gotf b/linode/producerimagesharegroup/tmpl/data_basic.gotf new file mode 100644 index 000000000..0f45fb6f8 --- /dev/null +++ b/linode/producerimagesharegroup/tmpl/data_basic.gotf @@ -0,0 +1,9 @@ +{{ define "producer_image_share_group_data_basic" }} + +{{ template "producer_image_share_group_basic" .}} + +data "linode_producer_image_share_group" "foobar" { + id = linode_producer_image_share_group.foobar.id +} + +{{ end }} \ No newline at end of file diff --git a/linode/producerimagesharegroup/tmpl/template.go b/linode/producerimagesharegroup/tmpl/template.go new file mode 100644 index 000000000..7a2705fee --- /dev/null +++ b/linode/producerimagesharegroup/tmpl/template.go @@ -0,0 +1,57 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + Label string + Description string +} + +type UpdateTemplateData struct { + Label string + Region string + ImageLabel1 string + ImageLabel2 string + ImageShareGroupLabel string + ImageShareGroupDescription string + Images []ShareGroupImageTemplate +} + +type ShareGroupImageTemplate struct { + ID string + Label string + Description string +} + +func DataBasic(t testing.TB, label, description string) string { + return acceptance.ExecuteTemplate(t, + "producer_image_share_group_data_basic", TemplateData{ + Label: label, + Description: description, + }) +} + +func Basic(t testing.TB, label, description string) string { + return acceptance.ExecuteTemplate(t, + "producer_image_share_group_basic", TemplateData{ + Label: label, + Description: description, + }) +} + +func Updates(t testing.TB, label, region, image_label_1, image_label_2, isg_label, isg_description string, images []ShareGroupImageTemplate) string { + return acceptance.ExecuteTemplate(t, + "producer_image_share_group_updates", UpdateTemplateData{ + Label: label, + Region: region, + ImageLabel1: image_label_1, + ImageLabel2: image_label_2, + ImageShareGroupLabel: isg_label, + ImageShareGroupDescription: isg_description, + Images: images, + }) +} diff --git a/linode/producerimagesharegroup/tmpl/updates.gotf b/linode/producerimagesharegroup/tmpl/updates.gotf new file mode 100644 index 000000000..3a2ee3b36 --- /dev/null +++ b/linode/producerimagesharegroup/tmpl/updates.gotf @@ -0,0 +1,55 @@ +{{ define "producer_image_share_group_updates" }} + +{{ template "e2e_test_firewall" . }} + +resource "linode_instance" "foobar" { + label = "{{ .Label }}" + group = "tf_test" + type = "g6-standard-1" + region = "{{ .Region }}" + + disk { + label = "disk" + size = 1000 + filesystem = "ext4" + } + + firewall_id = linode_firewall.e2e_test_firewall.id +} + +resource "linode_image" "foobar" { + linode_id = linode_instance.foobar.id + disk_id = linode_instance.foobar.disk.0.id + label = "{{ .ImageLabel1 }}" + description = "descriptive text" +} + +resource "linode_image" "barfoo" { + linode_id = linode_instance.foobar.id + disk_id = linode_instance.foobar.disk.0.id + label = "{{ .ImageLabel2 }}" + description = "descriptive text" +} + +resource "linode_producer_image_share_group" "foobar" { + label = "{{ .ImageShareGroupLabel }}" + description = "{{ .ImageShareGroupDescription }}" + + {{- if .Images }} + images = [ + {{- range .Images }} + { + id = "{{ .ID }}" + {{- if .Label }} + label = "{{ .Label }}" + {{- end }} + {{- if .Description }} + description = "{{ .Description }}" + {{- end }} + }, + {{- end }} + ] + {{- end }} +} + +{{ end }} \ No newline at end of file From e44d97694a98c320dff1bbb19e8466cfe3e4bb65 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 16 Oct 2025 16:00:48 -0400 Subject: [PATCH 04/29] Normalize null value for Images to [] for consistent behavior --- linode/producerimagesharegroup/framework_models.go | 9 +++++---- linode/producerimagesharegroup/framework_resource.go | 11 +++++++++++ .../framework_schema_resource.go | 5 +++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/linode/producerimagesharegroup/framework_models.go b/linode/producerimagesharegroup/framework_models.go index d88a43962..2aa08bcf5 100644 --- a/linode/producerimagesharegroup/framework_models.go +++ b/linode/producerimagesharegroup/framework_models.go @@ -2,6 +2,7 @@ package producerimagesharegroup import ( "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/linode/linodego" @@ -52,10 +53,10 @@ func (data *ResourceModel) FlattenImageShareGroup( ) // Images must persist in state across CRUD operations but is not returned by the API. It will be maintained - // manually as a part of Create, Update, and Read, so we only need to set it here if it is null so that it is - // properly typed. - if data.Images.IsNull() { - data.Images = types.ListNull(imageShareGroupImage.Type()) + // manually as a part of Create, Update, and Read, so we only need to set it here if it is null or unknown + // so that it is properly typed. + if data.Images.IsNull() || data.Images.IsUnknown() { + data.Images = types.ListValueMust(imageShareGroupImage.Type(), []attr.Value{}) } } diff --git a/linode/producerimagesharegroup/framework_resource.go b/linode/producerimagesharegroup/framework_resource.go index 32221ecfb..263360894 100644 --- a/linode/producerimagesharegroup/framework_resource.go +++ b/linode/producerimagesharegroup/framework_resource.go @@ -3,6 +3,7 @@ package producerimagesharegroup import ( "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" @@ -164,6 +165,11 @@ func (r *Resource) Read( state.Images = listVal + // Normalize null/unknown -> [] + if state.Images.IsNull() || state.Images.IsUnknown() { + state.Images = types.ListValueMust(imageShareGroupImage.Type(), []attr.Value{}) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } @@ -373,6 +379,11 @@ func (r *Resource) Update( } state.Images = listVal + // Normalize null/unknown -> [] + if state.Images.IsNull() || state.Images.IsUnknown() { + state.Images = types.ListValueMust(imageShareGroupImage.Type(), []attr.Value{}) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } diff --git a/linode/producerimagesharegroup/framework_schema_resource.go b/linode/producerimagesharegroup/framework_schema_resource.go index ed289a6b6..bb696d8da 100644 --- a/linode/producerimagesharegroup/framework_schema_resource.go +++ b/linode/producerimagesharegroup/framework_schema_resource.go @@ -4,6 +4,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" ) @@ -79,7 +80,11 @@ var frameworkResourceSchema = schema.Schema{ "images": schema.ListNestedAttribute{ Description: "The images to be shared using this Image Share Group.", Optional: true, + Computed: true, NestedObject: imageShareGroupImage, + PlanModifiers: []planmodifier.List{ + listplanmodifier.UseStateForUnknown(), + }, }, }, } From c2da8439ff8a762b48f3206c713cb3d06cad2e7b Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:37:59 -0400 Subject: [PATCH 05/29] Add logging for Linode interface resource (#2132) --- .../framework_default_route_model.go | 13 ++++++-- linode/linodeinterface/framework_models.go | 30 +++++++++---------- .../framework_public_models.go | 25 +++++++++++++--- linode/linodeinterface/framework_resource.go | 14 +++++++-- .../linodeinterface/framework_vlan_models.go | 14 +++++++-- .../linodeinterface/framework_vpc_models.go | 22 +++++++++++--- 6 files changed, 86 insertions(+), 32 deletions(-) diff --git a/linode/linodeinterface/framework_default_route_model.go b/linode/linodeinterface/framework_default_route_model.go index 4a8d58905..e711e174d 100644 --- a/linode/linodeinterface/framework_default_route_model.go +++ b/linode/linodeinterface/framework_default_route_model.go @@ -1,7 +1,10 @@ package linodeinterface import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/helper" ) @@ -11,7 +14,11 @@ type DefaultRouteAttrModel struct { IPv6 types.Bool `tfsdk:"ipv6"` } -func (plan *DefaultRouteAttrModel) GetCreateOrUpdateOptions(state *DefaultRouteAttrModel) (opts linodego.InterfaceDefaultRoute, shouldUpdate bool) { +func (plan *DefaultRouteAttrModel) GetCreateOrUpdateOptions( + ctx context.Context, + state *DefaultRouteAttrModel, +) (opts linodego.InterfaceDefaultRoute, shouldUpdate bool) { + tflog.Trace(ctx, "Enter DefaultRouteAttrModel.GetCreateOrUpdateOptions") if !plan.IPv4.IsUnknown() && (state == nil || !state.IPv4.Equal(plan.IPv4)) { opts.IPv4 = plan.IPv4.ValueBoolPointer() shouldUpdate = true @@ -26,8 +33,10 @@ func (plan *DefaultRouteAttrModel) GetCreateOrUpdateOptions(state *DefaultRouteA } func (data *DefaultRouteAttrModel) FlattenInterfaceDefaultRoute( - defaultRoute linodego.InterfaceDefaultRoute, preserveKnown bool, + ctx context.Context, defaultRoute linodego.InterfaceDefaultRoute, preserveKnown bool, ) { + tflog.Trace(ctx, "Enter DefaultRouteAttrModel.FlattenInterfaceDefaultRoute") + data.IPv4 = helper.KeepOrUpdateBoolPointer(data.IPv4, defaultRoute.IPv4, preserveKnown) data.IPv6 = helper.KeepOrUpdateBoolPointer(data.IPv6, defaultRoute.IPv6, preserveKnown) } diff --git a/linode/linodeinterface/framework_models.go b/linode/linodeinterface/framework_models.go index 2b57be3fd..c3822a315 100644 --- a/linode/linodeinterface/framework_models.go +++ b/linode/linodeinterface/framework_models.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/helper" ) @@ -23,18 +24,9 @@ type LinodeInterfaceModel struct { VPC types.Object `tfsdk:"vpc"` } -// Structs for Public Interfaces +func (state *LinodeInterfaceModel) GetIDs(ctx context.Context, diags *diag.Diagnostics) (linodeID int, id int) { + tflog.Trace(ctx, "Enter LinodeInterfaceModel.GetIDs") -// Structs for VLAN Interfaces - -// Structs for VPC Interfaces - -// type VPCAttrModel struct { -// IPv4 types.Bool `tfsdk:"ipv4"` -// IPv6 types.Bool `tfsdk:"ipv6"` -// } - -func (state *LinodeInterfaceModel) GetIDs(diags *diag.Diagnostics) (linodeID int, id int) { id, err := strconv.Atoi(state.ID.ValueString()) if err != nil { diags.AddError( @@ -50,10 +42,12 @@ func (state *LinodeInterfaceModel) GetIDs(diags *diag.Diagnostics) (linodeID int } func (plan *LinodeInterfaceModel) GetCreateOptions(ctx context.Context, diags *diag.Diagnostics) (opts linodego.LinodeInterfaceCreateOptions, linodeID int) { + tflog.Trace(ctx, "Enter LinodeInterfaceModel.GetCreateOptions") + if !plan.DefaultRoute.IsUnknown() && !plan.DefaultRoute.IsNull() { var planDefaultRoute DefaultRouteAttrModel plan.DefaultRoute.As(ctx, &planDefaultRoute, basetypes.ObjectAsOptions{}) - defaultRouteOpts, _ := planDefaultRoute.GetCreateOrUpdateOptions(nil) + defaultRouteOpts, _ := planDefaultRoute.GetCreateOrUpdateOptions(ctx, nil) opts.DefaultRoute = linodego.Pointer(defaultRouteOpts) } @@ -72,7 +66,7 @@ func (plan *LinodeInterfaceModel) GetCreateOptions(ctx context.Context, diags *d } else if !plan.VLAN.IsUnknown() && !plan.VLAN.IsNull() { var planVLANInterface VLANAttrModel plan.VLAN.As(ctx, &planVLANInterface, basetypes.ObjectAsOptions{}) - opts.VLAN = linodego.Pointer(planVLANInterface.GetCreateOptions()) + opts.VLAN = linodego.Pointer(planVLANInterface.GetCreateOptions(ctx)) } else if !plan.VPC.IsUnknown() && !plan.VPC.IsNull() { var planVPCInterface VPCAttrModel plan.VPC.As(ctx, &planVPCInterface, basetypes.ObjectAsOptions{}) @@ -89,6 +83,8 @@ func (plan *LinodeInterfaceModel) GetUpdateOptions( state LinodeInterfaceModel, diags *diag.Diagnostics, ) (opts linodego.LinodeInterfaceUpdateOptions) { + tflog.Trace(ctx, "Enter LinodeInterfaceModel.GetUpdateOptions") + if !plan.DefaultRoute.IsUnknown() && !plan.DefaultRoute.IsNull() { var planDefaultRoute DefaultRouteAttrModel var stateDefaultRoute *DefaultRouteAttrModel @@ -99,7 +95,7 @@ func (plan *LinodeInterfaceModel) GetUpdateOptions( state.DefaultRoute.As(ctx, &stateDefaultRoute, basetypes.ObjectAsOptions{}) } - if updatedDefaultRoute, ok := planDefaultRoute.GetCreateOrUpdateOptions(stateDefaultRoute); ok { + if updatedDefaultRoute, ok := planDefaultRoute.GetCreateOrUpdateOptions(ctx, stateDefaultRoute); ok { opts.DefaultRoute = linodego.Pointer(updatedDefaultRoute) } } @@ -142,6 +138,8 @@ func (plan *LinodeInterfaceModel) GetUpdateOptions( func (data *LinodeInterfaceModel) FlattenInterface( ctx context.Context, i linodego.LinodeInterface, preserveKnown bool, diags *diag.Diagnostics, ) { + tflog.Trace(ctx, "Enter LinodeInterfaceModel.FlattenInterface") + data.ID = helper.KeepOrUpdateString(data.ID, strconv.Itoa(i.ID), preserveKnown) flattenedDefaultRoute := helper.KeepOrUpdateSingleNestedAttributes( @@ -152,7 +150,7 @@ func (data *LinodeInterfaceModel) FlattenInterface( *isNull = true return } - dr.FlattenInterfaceDefaultRoute(*i.DefaultRoute, pk) + dr.FlattenInterfaceDefaultRoute(ctx, *i.DefaultRoute, pk) }, ) @@ -170,7 +168,7 @@ func (data *LinodeInterfaceModel) FlattenInterface( vlan.VLANLabel = helper.KeepOrUpdateValue(vlan.VLANLabel, types.StringNull(), pk) return } - vlan.FlattenVLANInterface(*i.VLAN, pk) + vlan.FlattenVLANInterface(ctx, *i.VLAN, pk) }, ) if diags.HasError() { diff --git a/linode/linodeinterface/framework_public_models.go b/linode/linodeinterface/framework_public_models.go index 8e0b342c0..fbdb962a7 100644 --- a/linode/linodeinterface/framework_public_models.go +++ b/linode/linodeinterface/framework_public_models.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/helper" ) @@ -56,6 +57,8 @@ func (plan *PublicAttrModel) GetCreateOrUpdateOptions( ctx context.Context, state *PublicAttrModel, ) (opts linodego.PublicInterfaceCreateOptions, shouldUpdate bool) { + tflog.Trace(ctx, "Enter PublicAttrModel.GetCreateOrUpdateOptions") + if !plan.IPv4.IsUnknown() && !plan.IPv4.IsNull() && (state == nil || !state.IPv4.Equal(plan.IPv4)) { var planPublicIPv4 PublicIPv4AttrModel plan.IPv4.As(ctx, &planPublicIPv4, basetypes.ObjectAsOptions{}) @@ -74,6 +77,8 @@ func (plan *PublicAttrModel) GetCreateOrUpdateOptions( } func (plan *PublicIPv4AttrModel) GetCreateOptions(ctx context.Context) (opts linodego.PublicInterfaceIPv4CreateOptions) { + tflog.Trace(ctx, "Enter PublicIPv4AttrModel.GetCreateOptions") + if !plan.Addresses.IsNull() && !plan.Addresses.IsUnknown() { length := len(plan.Addresses.Elements()) addressesOpts := make([]linodego.PublicInterfaceIPv4AddressCreateOptions, 0, length) @@ -85,7 +90,7 @@ func (plan *PublicIPv4AttrModel) GetCreateOptions(ctx context.Context) (opts lin plan.Addresses.ElementsAs(ctx, &addresses, false) for _, v := range addresses { - addressesOpts = append(addressesOpts, v.GetCreateOptions()) + addressesOpts = append(addressesOpts, v.GetCreateOptions(ctx)) } opts.Addresses = linodego.Pointer(addressesOpts) } @@ -94,6 +99,8 @@ func (plan *PublicIPv4AttrModel) GetCreateOptions(ctx context.Context) (opts lin } func (data *PublicIPv4AttrModel) FlattenPublicIPv4(ctx context.Context, ipv4 linodego.PublicInterfaceIPv4, preserveKnown bool, diags *diag.Diagnostics) { + tflog.Trace(ctx, "Enter PublicIPv4AttrModel.FlattenPublicIPv4") + // data.Address should never need to be flattened from a linodego struct because its values can // either be configured by the TF practitioner or defaulted to an empty list @@ -142,6 +149,8 @@ func (data *PublicIPv4AttrModel) FlattenPublicIPv4(ctx context.Context, ipv4 lin } func (data *PublicIPv6AttrModel) FlattenPublicIPv6(ctx context.Context, ipv6 linodego.PublicInterfaceIPv6, preserveKnown bool, diags *diag.Diagnostics) { + tflog.Trace(ctx, "Enter PublicIPv6AttrModel.FlattenPublicIPv6") + // data.Ranges should never need to be flattened from a linodego struct because its values can // either be configured by the TF practitioner or defaulted to an empty list @@ -199,6 +208,8 @@ func (data *PublicIPv6AttrModel) FlattenPublicIPv6(ctx context.Context, ipv6 lin } func (plan *PublicIPv6AttrModel) GetCreateOptions(ctx context.Context) (opts linodego.PublicInterfaceIPv6CreateOptions) { + tflog.Trace(ctx, "Enter PublicIPv6AttrModel.GetCreateOptions") + if !plan.Ranges.IsNull() && !plan.Ranges.IsUnknown() { length := len(plan.Ranges.Elements()) @@ -210,7 +221,7 @@ func (plan *PublicIPv6AttrModel) GetCreateOptions(ctx context.Context) (opts lin plan.Ranges.ElementsAs(ctx, &ranges, false) for _, v := range ranges { - rangesOpts = append(rangesOpts, v.GetCreateOptions()) + rangesOpts = append(rangesOpts, v.GetCreateOptions(ctx)) } opts.Ranges = linodego.Pointer(rangesOpts) } @@ -218,13 +229,17 @@ func (plan *PublicIPv6AttrModel) GetCreateOptions(ctx context.Context) (opts lin return opts } -func (plan *PublicIPv4AddressAttrModel) GetCreateOptions() (opts linodego.PublicInterfaceIPv4AddressCreateOptions) { +func (plan *PublicIPv4AddressAttrModel) GetCreateOptions(ctx context.Context) (opts linodego.PublicInterfaceIPv4AddressCreateOptions) { + tflog.Trace(ctx, "Enter PublicIPv4AddressAttrModel.GetCreateOptions") + opts.Address = helper.ValueStringPointerWithUnknownToNil(plan.Address) opts.Primary = helper.ValueBoolPointerWithUnknownToNil(plan.Primary) return opts } -func (plan *PublicIPv6RangeAttrModel) GetCreateOptions() (opts linodego.PublicInterfaceIPv6RangeCreateOptions) { +func (plan *PublicIPv6RangeAttrModel) GetCreateOptions(ctx context.Context) (opts linodego.PublicInterfaceIPv6RangeCreateOptions) { + tflog.Trace(ctx, "Enter PublicIPv6RangeAttrModel.GetCreateOptions") + opts.Range = plan.Range.ValueString() return opts } @@ -232,6 +247,8 @@ func (plan *PublicIPv6RangeAttrModel) GetCreateOptions() (opts linodego.PublicIn func (data *PublicAttrModel) FlattenPublicInterface( ctx context.Context, publicInterface linodego.PublicInterface, preserveKnown bool, diags *diag.Diagnostics, ) { + tflog.Trace(ctx, "Enter PublicAttrModel.FlattenPublicInterface") + flattenedPublicIPv4 := helper.KeepOrUpdateSingleNestedAttributesWithTypes( ctx, data.IPv4, publicIPv4Attribute.GetType().(types.ObjectType).AttrTypes, preserveKnown, diags, func(publicIPv4 *PublicIPv4AttrModel, isNull *bool, pk bool, d *diag.Diagnostics) { diff --git a/linode/linodeinterface/framework_resource.go b/linode/linodeinterface/framework_resource.go index 72e6c0077..ff2d54bd8 100644 --- a/linode/linodeinterface/framework_resource.go +++ b/linode/linodeinterface/framework_resource.go @@ -55,6 +55,9 @@ func (r *Resource) Create( return } + tflog.Debug(ctx, "client.CreateInterface(...)", map[string]any{ + "options": opts, + }) i, err := client.CreateInterface(ctx, linodeID, opts) if err != nil { resp.Diagnostics.AddError( @@ -99,13 +102,14 @@ func (r *Resource) Read( ctx = populateLogAttributes(ctx, state) - linodeID, id := state.GetIDs(&resp.Diagnostics) + linodeID, id := state.GetIDs(ctx, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } client := r.Meta.Client + tflog.Trace(ctx, "client.GetInterface(...)") linodeInterface, err := client.GetInterface(ctx, linodeID, id) if err != nil { if linodego.IsNotFound(err) { @@ -145,7 +149,7 @@ func (r *Resource) Update( ctx = populateLogAttributes(ctx, state) - linodeID, id := state.GetIDs(&resp.Diagnostics) + linodeID, id := state.GetIDs(ctx, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } @@ -157,6 +161,9 @@ func (r *Resource) Update( return } + tflog.Debug(ctx, "client.UpdateInterface(...)", map[string]any{ + "options": opts, + }) i, err := client.UpdateInterface(ctx, linodeID, id, opts) if err != nil { resp.Diagnostics.AddError( @@ -197,11 +204,12 @@ func (r *Resource) Delete( } client := r.Meta.Client - linodeID, id := state.GetIDs(&resp.Diagnostics) + linodeID, id := state.GetIDs(ctx, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } + tflog.Debug(ctx, "client.DeleteInterface(...)") err := client.DeleteInterface(ctx, linodeID, id) if err != nil { if linodego.IsNotFound(err) { diff --git a/linode/linodeinterface/framework_vlan_models.go b/linode/linodeinterface/framework_vlan_models.go index b980de188..6519011fe 100644 --- a/linode/linodeinterface/framework_vlan_models.go +++ b/linode/linodeinterface/framework_vlan_models.go @@ -1,8 +1,11 @@ package linodeinterface import ( + "context" + "github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/helper" ) @@ -12,12 +15,15 @@ type VLANAttrModel struct { VLANLabel types.String `tfsdk:"vlan_label"` } -func (data *VLANAttrModel) CopyFrom(other VLANAttrModel, preserveKnown bool) { +func (data *VLANAttrModel) CopyFrom(ctx context.Context, other VLANAttrModel, preserveKnown bool) { + tflog.Trace(ctx, "Enter VLANAttrModel.CopyFrom") + data.IPAMAddress = helper.KeepOrUpdateValue(data.IPAMAddress, other.IPAMAddress, preserveKnown) data.VLANLabel = helper.KeepOrUpdateValue(data.VLANLabel, other.VLANLabel, preserveKnown) } -func (plan *VLANAttrModel) GetCreateOptions() (vlan linodego.VLANInterface) { +func (plan *VLANAttrModel) GetCreateOptions(ctx context.Context) (vlan linodego.VLANInterface) { + tflog.Trace(ctx, "Enter VLANAttrModel.GetCreateOptions") if !plan.IPAMAddress.IsUnknown() { vlan.IPAMAddress = plan.IPAMAddress.ValueStringPointer() } @@ -28,8 +34,10 @@ func (plan *VLANAttrModel) GetCreateOptions() (vlan linodego.VLANInterface) { } func (data *VLANAttrModel) FlattenVLANInterface( - vlanInterface linodego.VLANInterface, preserveKnown bool, + ctx context.Context, vlanInterface linodego.VLANInterface, preserveKnown bool, ) { + tflog.Trace(ctx, "Enter VLANAttrModel.FlattenVLANInterface") + data.VLANLabel = helper.KeepOrUpdateString(data.VLANLabel, vlanInterface.VLANLabel, preserveKnown) data.IPAMAddress = helper.KeepOrUpdateValue(data.IPAMAddress, cidrtypes.NewIPv4PrefixPointerValue(vlanInterface.IPAMAddress), preserveKnown) } diff --git a/linode/linodeinterface/framework_vpc_models.go b/linode/linodeinterface/framework_vpc_models.go index 911c9b274..914b790b6 100644 --- a/linode/linodeinterface/framework_vpc_models.go +++ b/linode/linodeinterface/framework_vpc_models.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/helper" ) @@ -37,6 +38,8 @@ type VPCIPv4RangeAttrModel struct { } func (plan *VPCAttrModel) GetCreateOptions(ctx context.Context, diags *diag.Diagnostics) (opts linodego.VPCInterfaceCreateOptions) { + tflog.Trace(ctx, "Enter VPCAttrModel.GetCreateOptions") + opts.SubnetID = helper.FrameworkSafeInt64ToInt(plan.SubnetID.ValueInt64(), diags) if !plan.IPv4.IsUnknown() && !plan.IPv4.IsNull() { @@ -54,6 +57,8 @@ func (plan *VPCAttrModel) GetUpdateOptions( state *VPCAttrModel, diags *diag.Diagnostics, ) (opts linodego.VPCInterfaceUpdateOptions, shouldUpdate bool) { + tflog.Trace(ctx, "Enter VPCAttrModel.GetUpdateOptions") + // Note: SubnetID cannot be updated according to the API, so we don't include it if !plan.IPv4.IsUnknown() && !plan.IPv4.IsNull() { @@ -78,6 +83,7 @@ func (plan *VPCIPv4AttrModel) GetCreateOrUpdateOptions( ctx context.Context, state *VPCIPv4AttrModel, ) (opts linodego.VPCInterfaceIPv4CreateOptions, shouldUpdate bool) { + tflog.Trace(ctx, "Enter VPCIPv4AttrModel.GetCreateOrUpdateOptions") if !plan.Addresses.IsUnknown() && !plan.Addresses.IsNull() && (state == nil || !state.Addresses.Equal(plan.Addresses)) { length := len(plan.Addresses.Elements()) addresses := make([]VPCIPv4AddressAttrModel, 0, length) @@ -85,7 +91,7 @@ func (plan *VPCIPv4AttrModel) GetCreateOrUpdateOptions( addressOpts := make([]linodego.VPCInterfaceIPv4AddressCreateOptions, len(addresses)) for i, address := range addresses { - addressOpts[i] = address.GetCreateOptions() + addressOpts[i] = address.GetCreateOptions(ctx) } opts.Addresses = &addressOpts shouldUpdate = true @@ -98,7 +104,7 @@ func (plan *VPCIPv4AttrModel) GetCreateOrUpdateOptions( rangeOpts := make([]linodego.VPCInterfaceIPv4RangeCreateOptions, len(ranges)) for i, r := range ranges { - rangeOpts[i] = r.GetCreateOptions() + rangeOpts[i] = r.GetCreateOptions(ctx) } opts.Ranges = &rangeOpts shouldUpdate = true @@ -107,7 +113,9 @@ func (plan *VPCIPv4AttrModel) GetCreateOrUpdateOptions( return opts, shouldUpdate } -func (plan *VPCIPv4AddressAttrModel) GetCreateOptions() linodego.VPCInterfaceIPv4AddressCreateOptions { +func (plan *VPCIPv4AddressAttrModel) GetCreateOptions(ctx context.Context) linodego.VPCInterfaceIPv4AddressCreateOptions { + tflog.Trace(ctx, "Enter VPCIPv4AddressAttrModel.GetCreateOptions") + opts := linodego.VPCInterfaceIPv4AddressCreateOptions{} if !plan.Address.IsUnknown() { @@ -125,7 +133,9 @@ func (plan *VPCIPv4AddressAttrModel) GetCreateOptions() linodego.VPCInterfaceIPv return opts } -func (plan *VPCIPv4RangeAttrModel) GetCreateOptions() linodego.VPCInterfaceIPv4RangeCreateOptions { +func (plan *VPCIPv4RangeAttrModel) GetCreateOptions(ctx context.Context) linodego.VPCInterfaceIPv4RangeCreateOptions { + tflog.Trace(ctx, "Enter VPCIPv4RangeAttrModel.GetCreateOptions") + return linodego.VPCInterfaceIPv4RangeCreateOptions{ Range: plan.Range.ValueString(), } @@ -134,6 +144,8 @@ func (plan *VPCIPv4RangeAttrModel) GetCreateOptions() linodego.VPCInterfaceIPv4R func (data *VPCAttrModel) FlattenVPCInterface( ctx context.Context, vpcInterface linodego.VPCInterface, preserveKnown bool, diags *diag.Diagnostics, ) { + tflog.Trace(ctx, "Enter VPCAttrModel.FlattenVPCInterface") + data.SubnetID = helper.KeepOrUpdateInt64(data.SubnetID, int64(vpcInterface.SubnetID), preserveKnown) flattenedIPv4 := helper.KeepOrUpdateSingleNestedAttributesWithTypes( @@ -151,6 +163,8 @@ func (data *VPCAttrModel) FlattenVPCInterface( } func (data *VPCIPv4AttrModel) FlattenVPCIPv4(ctx context.Context, ipv4 linodego.VPCInterfaceIPv4, preserveKnown bool, diags *diag.Diagnostics) { + tflog.Trace(ctx, "Enter VPCIPv4AttrModel.FlattenVPCIPv4") + // When the object is null/unknown, the types of attributes of the object won't be filled by object.As(...) in the // helper function `KeepOrUpdateSingleNestedAttributeWithTypes`, so resetting manually here. if data.Addresses.IsNull() { From 5fa026b45a70fabe80284483d713ebd26e59186f Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:38:07 -0400 Subject: [PATCH 06/29] Add 30s waiting period for OBJ temp key for E2/3 endpoints; update docs (#2133) --- docs/index.md | 2 +- docs/resources/object_storage_bucket.md | 2 +- linode/obj/framework_models.go | 7 ++- linode/obj/framework_resource.go | 8 +-- linode/obj/helpers.go | 65 +++++++++++++++++++++---- linode/objbucket/resource.go | 20 ++++++-- 6 files changed, 83 insertions(+), 21 deletions(-) diff --git a/docs/index.md b/docs/index.md index d90f498ff..090450af4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -92,7 +92,7 @@ This section outlines commonly used provider configuration options. The Object Secret Key can also be specified using the `LINODE_OBJ_SECRET_KEY` shell environment variable. -* `obj_use_temp_keys` - (Optional) If true, temporary object keys will be created implicitly at apply-time for the [linode_object_storage_bucket](resources/object_storage_bucket.md) and [linode_object_storage_object](resources/object_storage_object.md) resource to use. +* `obj_use_temp_keys` - (Optional) If true, a temporary object storage keys pair will be created implicitly at apply-time for each of the [linode_object_storage_bucket](resources/object_storage_bucket.md) and [linode_object_storage_object](resources/object_storage_object.md) resources to use. Due to current technical limitations, a temporary keys pair for E2/E3 endpoints takes 30 seconds to become effective, so enabling temporary keys for E2/E3 endpoints is not recommended. * `obj_bucket_force_delete` - (Optional) If true, all objects and versions will purged from a [linode_object_storage_bucket](resources/object_storage_bucket.md) before it is destroyed. diff --git a/docs/resources/object_storage_bucket.md b/docs/resources/object_storage_bucket.md index 094bdf607..c6b352f2e 100644 --- a/docs/resources/object_storage_bucket.md +++ b/docs/resources/object_storage_bucket.md @@ -109,7 +109,7 @@ For example, `us-mia-1` cluster can be translated into `us-mia` region. Exactly * `s3_endpoint` - (Optional) The user's s3 endpoint URL, based on the `endpoint_type` and `region`. -* `cors_enabled` - (Optional) If true, the bucket will have CORS enabled for all origins. +* `cors_enabled` - (Optional) If true, the bucket will have CORS enabled for all origins. Not supported by E2/E3 endpoints. * `versioning` - (Optional) Whether to enable versioning. Once you version-enable a bucket, it can never return to an unversioned state. You can, however, suspend versioning on that bucket. (Requires `access_key` and `secret_key`) diff --git a/linode/obj/framework_models.go b/linode/obj/framework_models.go index c6cef4c9c..429f50e51 100644 --- a/linode/obj/framework_models.go +++ b/linode/obj/framework_models.go @@ -80,6 +80,7 @@ func (data ResourceModel) GetObjectStorageKeys( client *linodego.Client, config *helper.FrameworkProviderModel, permissions string, + endpointType *linodego.ObjectStorageEndpointType, diags *diag.Diagnostics, ) (*ObjectKeys, func()) { result := &ObjectKeys{} @@ -99,7 +100,7 @@ func (data ResourceModel) GetObjectStorageKeys( } if config.ObjUseTempKeys.ValueBool() { - objKey := fwCreateTempKeys(ctx, client, data.Bucket.ValueString(), data.GetRegionOrCluster(ctx), permissions, diags) + objKey := fwCreateTempKeys(ctx, client, data.Bucket.ValueString(), data.GetRegionOrCluster(ctx), permissions, nil, diags) if diags.HasError() { return nil, nil } @@ -107,7 +108,9 @@ func (data ResourceModel) GetObjectStorageKeys( result.AccessKey = objKey.AccessKey result.SecretKey = objKey.SecretKey - teardownTempKeysCleanUp := func() { cleanUpTempKeys(ctx, client, objKey.ID) } + teardownTempKeysCleanUp := func() { + cleanUpTempKeys(ctx, client, objKey.ID) + } return result, teardownTempKeysCleanUp } diff --git a/linode/obj/framework_resource.go b/linode/obj/framework_resource.go index 8a15b59a6..87b010ac8 100644 --- a/linode/obj/framework_resource.go +++ b/linode/obj/framework_resource.go @@ -55,7 +55,7 @@ func (r *Resource) Create( plan.ComputeEndpointIfUnknown(ctx, client, &resp.Diagnostics) s3client, teardownKeys := getS3ClientFromModel( - ctx, client, config, plan, READ_WRITE_PERMISSION, &resp.Diagnostics, + ctx, client, config, plan, READ_WRITE_PERMISSION, nil, &resp.Diagnostics, ) if teardownKeys != nil { @@ -145,7 +145,7 @@ func (r *Resource) Read( } s3client, teardownKeys := getS3ClientFromModel( - ctx, client, config, state, READ_PERMISSION, &resp.Diagnostics, + ctx, client, config, state, READ_PERMISSION, nil, &resp.Diagnostics, ) if teardownKeys != nil { @@ -196,7 +196,7 @@ func (r *Resource) Update( plan.ComputeEndpointIfUnknown(ctx, client, &resp.Diagnostics) s3client, teardownKeys := getS3ClientFromModel( - ctx, client, config, plan, READ_WRITE_PERMISSION, &resp.Diagnostics, + ctx, client, config, plan, READ_WRITE_PERMISSION, nil, &resp.Diagnostics, ) if teardownKeys != nil { @@ -256,7 +256,7 @@ func (r *Resource) Delete( config := r.Meta.Config s3client, teardownKeys := getS3ClientFromModel( - ctx, client, config, state, READ_WRITE_PERMISSION, &resp.Diagnostics, + ctx, client, config, state, READ_WRITE_PERMISSION, nil, &resp.Diagnostics, ) if teardownKeys != nil { diff --git a/linode/obj/helpers.go b/linode/obj/helpers.go index 7fd822863..72dca584b 100644 --- a/linode/obj/helpers.go +++ b/linode/obj/helpers.go @@ -37,9 +37,10 @@ func getS3ClientFromModel( config *helper.FrameworkProviderModel, data ResourceModel, permission string, + endpointType *linodego.ObjectStorageEndpointType, diags *diag.Diagnostics, ) (*s3.Client, func()) { - keys, teardownKeys := data.GetObjectStorageKeys(ctx, client, config, permission, diags) + keys, teardownKeys := data.GetObjectStorageKeys(ctx, client, config, permission, endpointType, diags) if diags.HasError() { return nil, teardownKeys } @@ -82,13 +83,14 @@ func isCluster(regionOrCluster string) bool { func fwCreateTempKeys( ctx context.Context, client *linodego.Client, - bucket, regionOrCluster, permissions string, + bucketLabel, regionOrCluster, permissions string, + endpointType *linodego.ObjectStorageEndpointType, diags *diag.Diagnostics, ) *linodego.ObjectStorageKey { tflog.Debug(ctx, "Create temporary object storage access keys implicitly.") tempBucketAccess := linodego.ObjectStorageKeyBucketAccess{ - BucketName: bucket, + BucketName: bucketLabel, Permissions: permissions, } @@ -101,7 +103,7 @@ func fwCreateTempKeys( } createOpts := linodego.ObjectStorageKeyCreateOptions{ - Label: fmt.Sprintf("temp_%s_%v", bucket, time.Now().Unix()), + Label: fmt.Sprintf("temp_%s_%v", bucketLabel, time.Now().Unix()), BucketAccess: &[]linodego.ObjectStorageKeyBucketAccess{tempBucketAccess}, } @@ -115,21 +117,51 @@ func fwCreateTempKeys( return nil } + if endpointType == nil { + et, err := getBucketEndpointType(ctx, client, regionOrCluster, bucketLabel) + if err != nil { + diags.AddWarning( + "Can't determine the type of the object storage endpoint. If the it's an E2/E3 OBJ clusters, "+ + "it may lead to an issue that temporary limited key is used before becoming effective", + err.Error(), + ) + } else { + endpointType = &et + } + } + + // OBJ limited key for OBJ gen2 takes at most 30s to refresh the cache can becomes effective + if endpointType != nil && *endpointType != linodego.ObjectStorageEndpointE0 && *endpointType != linodego.ObjectStorageEndpointE1 { + time.Sleep(30 * time.Second) + } + return keys } +func getBucketEndpointType( + ctx context.Context, client *linodego.Client, cluster, label string, +) (linodego.ObjectStorageEndpointType, error) { + bucket, err := client.GetObjectStorageBucket(ctx, cluster, label) + if err != nil { + return "", err + } + + return bucket.EndpointType, nil +} + // createTempKeys creates temporary Object Storage Keys to use. // The temporary keys are scoped only to the target cluster and bucket with limited permissions. // Keys only exist for the duration of the apply time. func createTempKeys( ctx context.Context, client *linodego.Client, - bucket, regionOrCluster, permissions string, + bucketLabel, regionOrCluster, permissions string, + endpointType *linodego.ObjectStorageEndpointType, ) (*linodego.ObjectStorageKey, sdkv2diag.Diagnostics) { tflog.Debug(ctx, "Create temporary object storage access keys implicitly.") tempBucketAccess := linodego.ObjectStorageKeyBucketAccess{ - BucketName: bucket, + BucketName: bucketLabel, Permissions: permissions, } @@ -144,7 +176,6 @@ func createTempKeys( // too long, then truncate it. // We use 16 characters for `temp__{timestamp}`, so the maximum length of a // full bucket name is 34. - bucketLabel := bucket if len(bucketLabel) > 34 { bucketLabel = bucketLabel[:34] } @@ -161,6 +192,20 @@ func createTempKeys( if err != nil { return nil, sdkv2diag.FromErr(err) } + if endpointType == nil { + et, err := getBucketEndpointType(ctx, client, regionOrCluster, bucketLabel) + if err != nil { + tflog.Warn(ctx, fmt.Sprintf("Can't determine the type of the object storage endpoint: %s", err.Error())) + } else { + endpointType = &et + } + } + + // OBJ limited key for OBJ gen2 takes at most 30s to refresh the cache can becomes effective + if endpointType != nil && *endpointType != linodego.ObjectStorageEndpointE0 && *endpointType != linodego.ObjectStorageEndpointE1 { + time.Sleep(30 * time.Second) + } + // panic(fmt.Sprintf("%v", endpointType)) return keys, nil } @@ -197,6 +242,7 @@ func GetObjKeys( config *helper.Config, client linodego.Client, bucket, regionOrCluster, permission string, + endpointType *linodego.ObjectStorageEndpointType, ) (ObjectKeys, sdkv2diag.Diagnostics, func()) { var teardownTempKeysCleanUp func() = nil @@ -211,7 +257,7 @@ func GetObjKeys( objKeys = providerKeys } else if config.ObjUseTempKeys { // Implicitly create temporary object storage keys - keys, diag := createTempKeys(ctx, &client, bucket, regionOrCluster, permission) + keys, diag := createTempKeys(ctx, &client, bucket, regionOrCluster, permission, endpointType) if diag != nil { return objKeys, diag, nil } @@ -250,9 +296,10 @@ func putObjectWithRetries( if _, err := s3client.PutObject(ctx, putInput); err != nil { tflog.Debug(ctx, fmt.Sprintf( - "Failed to put Bucket (%v) Object (%v): %s. Retrying...", + "Failed to put Bucket (%v) Object (%v) with input %v: %s. Retrying...", aws.ToString(putInput.Bucket), aws.ToString(putInput.Key), + putInput, err.Error(), ), ) diff --git a/linode/objbucket/resource.go b/linode/objbucket/resource.go index 0babd53ef..550d00ed2 100644 --- a/linode/objbucket/resource.go +++ b/linode/objbucket/resource.go @@ -93,7 +93,7 @@ func readResource( "lifecyclePresent": lifecyclePresent, }) - objKeys, diags, teardownKeysCleanUp := obj.GetObjKeys(ctx, d, config, client, bucket.Label, regionOrCluster, "read_only") + objKeys, diags, teardownKeysCleanUp := obj.GetObjKeys(ctx, d, config, client, bucket.Label, regionOrCluster, "read_only", &bucket.EndpointType) if diags != nil { return diags } @@ -228,9 +228,15 @@ func updateResource( config := meta.(*helper.ProviderMeta).Config regionOrCluster := helper.GetRegionOrCluster(d) - bucket := d.Get("label").(string) + bucketLabel := d.Get("label").(string) - objKeys, diags, teardownKeysCleanUp := obj.GetObjKeys(ctx, d, config, client, bucket, regionOrCluster, "read_write") + et, etOK := d.GetOk("endpoint_type") + var endpointType *linodego.ObjectStorageEndpointType + if etOK { + endpointType = linodego.Pointer(linodego.ObjectStorageEndpointType(et.(string))) + } + + objKeys, diags, teardownKeysCleanUp := obj.GetObjKeys(ctx, d, config, client, bucketLabel, regionOrCluster, "read_write", endpointType) if diags != nil { return diags } @@ -277,7 +283,13 @@ func deleteResource( } if config.ObjBucketForceDelete { - objKeys, diags, teardownKeysCleanUp := obj.GetObjKeys(ctx, d, config, client, label, regionOrCluster, "read_write") + et, etOK := d.GetOk("endpoint_type") + var endpointType *linodego.ObjectStorageEndpointType + if etOK { + endpointType = linodego.Pointer(linodego.ObjectStorageEndpointType(et.(string))) + } + + objKeys, diags, teardownKeysCleanUp := obj.GetObjKeys(ctx, d, config, client, label, regionOrCluster, "read_write", endpointType) if diags != nil { return diags } From 44d3e914af14e28fb89c6a78fb8b4b5887608907 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Tue, 21 Oct 2025 11:44:40 -0400 Subject: [PATCH 07/29] Added Image Share Groups datasource and Image Share Group Member resource --- .../producer_image_share_groups.md | 79 ++++++++ linode/framework_provider.go | 4 + .../framework_resource.go | 2 +- .../framework_models.go | 50 +++++ .../framework_resource.go | 190 ++++++++++++++++++ .../framework_schema_resource.go | 52 +++++ .../datasource_test.go | 64 ++++++ .../framework_datasource.go | 78 +++++++ .../framework_models.go | 32 +++ .../framework_schema_datasource.go | 33 +++ .../tmpl/data_basic.gotf | 54 +++++ .../producerimagesharegroups/tmpl/template.go | 20 ++ 12 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 docs/data-sources/producer_image_share_groups.md create mode 100644 linode/producerimagesharegroupmember/framework_models.go create mode 100644 linode/producerimagesharegroupmember/framework_resource.go create mode 100644 linode/producerimagesharegroupmember/framework_schema_resource.go create mode 100644 linode/producerimagesharegroups/datasource_test.go create mode 100644 linode/producerimagesharegroups/framework_datasource.go create mode 100644 linode/producerimagesharegroups/framework_models.go create mode 100644 linode/producerimagesharegroups/framework_schema_datasource.go create mode 100644 linode/producerimagesharegroups/tmpl/data_basic.gotf create mode 100644 linode/producerimagesharegroups/tmpl/template.go diff --git a/docs/data-sources/producer_image_share_groups.md b/docs/data-sources/producer_image_share_groups.md new file mode 100644 index 000000000..d555df668 --- /dev/null +++ b/docs/data-sources/producer_image_share_groups.md @@ -0,0 +1,79 @@ +--- +page_title: "Linode: linode_producer_image_share_groups" +description: |- + Lists Image Share Groups on your account. +--- + +# Data Source: linode\producer\_image\_share\_groups + +Provides information about a list of Image Share Groups that match a set of filters. +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +The following example shows how one might use this data source to list Image Share Groups. + +```hcl +data "linode_producer_image_share_groups" "all" {} + +data "linode_producer_image_share_groups" "filtered" { + filter { + name = "label" + values = ["my-label"] + } +} + +output "all-share-groups" { + value = data.linode_producer_image_share_groups.all.image_share_groups +} + +output "filtered-share-groups" { + value = data.linode_producer_image_share_groups.filtered.image_share_groups +} +``` + +## Argument Reference + +The following arguments are supported: + +* [`filter`](#filter) - (Optional) A set of filters used to select Image Share Groups that meet certain requirements. + +### Filter + +* `name` - (Required) The name of the field to filter by. See the [Filterable Fields section](#filterable-fields) for a complete list of filterable fields. + +* `values` - (Required) A list of values for the filter to allow. These values should all be in string form. + +* `match_by` - (Optional) The method to match the field by. (`exact`, `regex`, `substring`; default `exact`) + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The ID of the Image Share Group. + +* `uuid` - The UUID of the Image Share Group. + +* `label` - The label of the Image Share Group. + +* `description` - The description of the Image Share Group. + +* `is_suspended` - Whether the Image Share Group is suspended. + +* `images_count` - The number of images in the Image Share Group. + +* `members_count` - The number of members in the Image Share Group. + +* `created` - The date and time the Image Share Group was created. + +* `updated` - The date and time the Image Share Group was last updated. + +* `expiry` - The date and time the Image Share Group will expire. + +## Filterable Fields + +* `id` + +* `label` + +* `is_suspended` diff --git a/linode/framework_provider.go b/linode/framework_provider.go index 7150c098c..fa8909373 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -76,6 +76,8 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/placementgroupassignment" "github.com/linode/terraform-provider-linode/v3/linode/placementgroups" "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroup" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupmember" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroups" "github.com/linode/terraform-provider-linode/v3/linode/profile" "github.com/linode/terraform-provider-linode/v3/linode/rdns" "github.com/linode/terraform-provider-linode/v3/linode/region" @@ -254,6 +256,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res obj.NewResource, databasemysqlv2.NewResource, producerimagesharegroup.NewResource, + producerimagesharegroupmember.NewResource, } } @@ -335,5 +338,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource objquota.NewDataSource, objquotas.NewDataSource, producerimagesharegroup.NewDataSource, + producerimagesharegroups.NewDataSource, } } diff --git a/linode/producerimagesharegroup/framework_resource.go b/linode/producerimagesharegroup/framework_resource.go index 263360894..75ef3c945 100644 --- a/linode/producerimagesharegroup/framework_resource.go +++ b/linode/producerimagesharegroup/framework_resource.go @@ -3,8 +3,8 @@ package producerimagesharegroup import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" diff --git a/linode/producerimagesharegroupmember/framework_models.go b/linode/producerimagesharegroupmember/framework_models.go new file mode 100644 index 000000000..ab7e41a20 --- /dev/null +++ b/linode/producerimagesharegroupmember/framework_models.go @@ -0,0 +1,50 @@ +package producerimagesharegroupmember + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type ResourceModel struct { + ShareGroupID types.Int64 `tfsdk:"sharegroup_id"` + Token types.String `tfsdk:"token"` + Label types.String `tfsdk:"label"` + TokenUUID types.String `tfsdk:"token_uuid"` + Status types.String `tfsdk:"status"` + Created timetypes.RFC3339 `tfsdk:"created"` + Updated timetypes.RFC3339 `tfsdk:"updated"` + Expiry timetypes.RFC3339 `tfsdk:"expiry"` +} + +func (data *ResourceModel) FlattenImageShareGroupMember( + member *linodego.ImageShareGroupMember, + preserveKnown bool, +) { + // We do not touch ShareGroupID Token as they are not returned by the API and must be preserved as-is. + + data.Label = helper.KeepOrUpdateString(data.Label, member.Label, preserveKnown) + data.TokenUUID = helper.KeepOrUpdateString(data.TokenUUID, member.TokenUUID, preserveKnown) + data.Status = helper.KeepOrUpdateString(data.Status, member.Status, preserveKnown) + data.Created = helper.KeepOrUpdateValue( + data.Created, timetypes.NewRFC3339TimePointerValue(member.Created), preserveKnown, + ) + data.Updated = helper.KeepOrUpdateValue( + data.Updated, timetypes.NewRFC3339TimePointerValue(member.Updated), preserveKnown, + ) + data.Expiry = helper.KeepOrUpdateValue( + data.Expiry, timetypes.NewRFC3339TimePointerValue(member.Expiry), preserveKnown, + ) +} + +func (m *ResourceModel) CopyFrom(other ResourceModel, preserveKnown bool) { + m.ShareGroupID = helper.KeepOrUpdateValue(m.ShareGroupID, other.ShareGroupID, preserveKnown) + m.Token = helper.KeepOrUpdateValue(m.Token, other.Token, preserveKnown) + m.Label = helper.KeepOrUpdateValue(m.Label, other.Label, preserveKnown) + m.TokenUUID = helper.KeepOrUpdateValue(m.TokenUUID, other.TokenUUID, preserveKnown) + m.Status = helper.KeepOrUpdateValue(m.Status, other.Status, preserveKnown) + m.Created = helper.KeepOrUpdateValue(m.Created, other.Created, preserveKnown) + m.Updated = helper.KeepOrUpdateValue(m.Updated, other.Updated, preserveKnown) + m.Expiry = helper.KeepOrUpdateValue(m.Expiry, other.Expiry, preserveKnown) +} diff --git a/linode/producerimagesharegroupmember/framework_resource.go b/linode/producerimagesharegroupmember/framework_resource.go new file mode 100644 index 000000000..bacc38b6d --- /dev/null +++ b/linode/producerimagesharegroupmember/framework_resource.go @@ -0,0 +1,190 @@ +package producerimagesharegroupmember + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "linode_producer_image_share_group_member", + IDType: types.Int64Type, + Schema: &frameworkResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + tflog.Debug(ctx, "Create "+r.Config.Name) + + var plan ResourceModel + client := r.Meta.Client + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + createOpts := linodego.ImageShareGroupAddMemberOptions{ + Token: plan.Token.ValueString(), + Label: plan.Label.ValueString(), + } + + tflog.Debug(ctx, "client.ImageShareGroupAddMember(...)", map[string]any{ + "options": createOpts, + }) + + shareGroupID := helper.FrameworkSafeInt64ToInt(plan.ShareGroupID.ValueInt64(), &resp.Diagnostics) + + member, err := client.ImageShareGroupAddMember(ctx, shareGroupID, createOpts) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create Image Share Group Member.", + err.Error(), + ) + return + } + + plan.FlattenImageShareGroupMember(member, true) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + tflog.Debug(ctx, "Read "+r.Config.Name) + + client := r.Meta.Client + + var state ResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + shareGroupID := helper.FrameworkSafeInt64ToInt(state.ShareGroupID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + tokenUUID := state.TokenUUID.ValueString() + + member, err := client.ImageShareGroupGetMember(ctx, shareGroupID, tokenUUID) + if err != nil { + resp.Diagnostics.AddError( + "Failed to read Image Share Group Member.", + err.Error(), + ) + return + } + + state.FlattenImageShareGroupMember(member, true) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + tflog.Debug(ctx, "Update "+r.Config.Name) + + client := r.Meta.Client + + var plan ResourceModel + var state ResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + shareGroupID := helper.FrameworkSafeInt64ToInt(state.ShareGroupID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + tokenUUID := state.TokenUUID.ValueString() + + var updateOpts linodego.ImageShareGroupUpdateMemberOptions + shouldUpdate := false + + if !state.Label.Equal(plan.Label) { + shouldUpdate = true + updateOpts.Label = plan.Label.ValueString() + } + + if shouldUpdate { + tflog.Debug(ctx, "client.ImageShareGroupUpdateMember(...)", map[string]any{ + "options": updateOpts, + }) + + member, err := client.ImageShareGroupUpdateMember(ctx, shareGroupID, tokenUUID, updateOpts) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to update Image Share Group Member (%d).", shareGroupID), + err.Error(), + ) + return + } + + plan.FlattenImageShareGroupMember(member, false) + if resp.Diagnostics.HasError() { + return + } + } + plan.CopyFrom(state, true) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + tflog.Debug(ctx, "Delete "+r.Config.Name) + + var state ResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + shareGroupID := helper.FrameworkSafeInt64ToInt(state.ShareGroupID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + tokenUUID := state.TokenUUID.ValueString() + + client := r.Meta.Client + + err := client.ImageShareGroupRemoveMember(ctx, shareGroupID, tokenUUID) + if err != nil { + resp.Diagnostics.AddError("Failed to Delete Image Share Group Member.", err.Error()) + return + } +} diff --git a/linode/producerimagesharegroupmember/framework_schema_resource.go b/linode/producerimagesharegroupmember/framework_schema_resource.go new file mode 100644 index 000000000..d444d1245 --- /dev/null +++ b/linode/producerimagesharegroupmember/framework_schema_resource.go @@ -0,0 +1,52 @@ +package producerimagesharegroupmember + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +var frameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "sharegroup_id": schema.Int64Attribute{ + Description: "The ID of the Image Share Group the member belongs to.", + Required: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "token": schema.StringAttribute{ + Description: "The one-time-use token provided by the prospective member.", + Required: true, + Sensitive: true, + }, + "label": schema.StringAttribute{ + Description: "The label of the member.", + Required: true, + }, + "token_uuid": schema.StringAttribute{ + Description: "The UUID of member's token.", + Computed: true, + }, + "status": schema.StringAttribute{ + Description: "The status of the member.", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "When this member was created.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "updated": schema.StringAttribute{ + Description: "When this member was last updated.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "expiry": schema.StringAttribute{ + Description: "When this member will expire.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + }, +} diff --git a/linode/producerimagesharegroups/datasource_test.go b/linode/producerimagesharegroups/datasource_test.go new file mode 100644 index 000000000..7b55f42f9 --- /dev/null +++ b/linode/producerimagesharegroups/datasource_test.go @@ -0,0 +1,64 @@ +//go:build integration || producerimagesharegroups + +package producerimagesharegroups_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroups/tmpl" +) + +func TestAccDataSourceImageShareGroups_basic(t *testing.T) { + t.Parallel() + + const dsAll = "data.linode_producer_image_share_groups.all" + const dsByLabel = "data.linode_producer_image_share_groups.by_label" + const dsByID = "data.linode_producer_image_share_groups.by_id" + const dsByIsSuspended = "data.linode_producer_image_share_groups.by_is_suspended" + + label1 := acctest.RandomWithPrefix("tf-test") + label2 := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, label1, label2), + Check: resource.ComposeTestCheckFunc( + acceptance.CheckResourceAttrGreaterThan(dsAll, "image_share_groups.#", 1), + resource.TestCheckResourceAttrSet(dsAll, "image_share_groups.0.id"), + resource.TestCheckResourceAttrSet(dsAll, "image_share_groups.0.uuid"), + resource.TestCheckResourceAttrSet(dsAll, "image_share_groups.0.label"), + resource.TestCheckResourceAttrSet(dsAll, "image_share_groups.0.is_suspended"), + resource.TestCheckResourceAttrSet(dsAll, "image_share_groups.0.images_count"), + resource.TestCheckResourceAttrSet(dsAll, "image_share_groups.0.members_count"), + resource.TestCheckResourceAttrSet(dsAll, "image_share_groups.0.created"), + + resource.TestCheckResourceAttr(dsByLabel, "image_share_groups.#", "1"), + resource.TestCheckResourceAttrSet(dsByLabel, "image_share_groups.0.id"), + resource.TestCheckResourceAttrSet(dsByLabel, "image_share_groups.0.uuid"), + resource.TestCheckResourceAttr(dsByLabel, "image_share_groups.0.label", label1), + resource.TestCheckResourceAttrSet(dsByLabel, "image_share_groups.0.is_suspended"), + resource.TestCheckResourceAttrSet(dsByLabel, "image_share_groups.0.images_count"), + resource.TestCheckResourceAttrSet(dsByLabel, "image_share_groups.0.members_count"), + resource.TestCheckResourceAttrSet(dsByLabel, "image_share_groups.0.created"), + + resource.TestCheckResourceAttr(dsByID, "image_share_groups.#", "1"), + resource.TestCheckResourceAttrSet(dsByID, "image_share_groups.0.id"), + resource.TestCheckResourceAttrSet(dsByID, "image_share_groups.0.uuid"), + resource.TestCheckResourceAttr(dsByID, "image_share_groups.0.label", label2), + resource.TestCheckResourceAttrSet(dsByID, "image_share_groups.0.is_suspended"), + resource.TestCheckResourceAttrSet(dsByID, "image_share_groups.0.images_count"), + resource.TestCheckResourceAttrSet(dsByID, "image_share_groups.0.members_count"), + resource.TestCheckResourceAttrSet(dsByID, "image_share_groups.0.created"), + + acceptance.CheckResourceAttrGreaterThan(dsByIsSuspended, "image_share_groups.#", 1), + ), + }, + }, + }) +} diff --git a/linode/producerimagesharegroups/framework_datasource.go b/linode/producerimagesharegroups/framework_datasource.go new file mode 100644 index 000000000..33da97c85 --- /dev/null +++ b/linode/producerimagesharegroups/framework_datasource.go @@ -0,0 +1,78 @@ +package producerimagesharegroups + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_producer_image_share_groups", + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (r *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+r.Config.Name) + + var data ImageShareGroupFilterModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + id, d := filterConfig.GenerateID(data.Filters) + if d != nil { + resp.Diagnostics.Append(d) + return + } + data.ID = id + + result, d := filterConfig.GetAndFilter( + ctx, + r.Meta.Client, + data.Filters, + listImageShareGroups, + data.Order, + data.OrderBy, + ) + if d != nil { + resp.Diagnostics.Append(d) + return + } + + data.ParseImageShareGroups(helper.AnySliceToTyped[linodego.ProducerImageShareGroup](result)) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func listImageShareGroups(ctx context.Context, client *linodego.Client, filter string) ([]any, error) { + sgs, err := client.ListImageShareGroups(ctx, &linodego.ListOptions{ + Filter: filter, + }) + if err != nil { + return nil, err + } + + return helper.TypedSliceToAny(sgs), nil +} diff --git a/linode/producerimagesharegroups/framework_models.go b/linode/producerimagesharegroups/framework_models.go new file mode 100644 index 000000000..cacca4c0b --- /dev/null +++ b/linode/producerimagesharegroups/framework_models.go @@ -0,0 +1,32 @@ +package producerimagesharegroups + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroup" +) + +type ImageShareGroupFilterModel struct { + ID types.String `tfsdk:"id"` + Filters frameworkfilter.FiltersModelType `tfsdk:"filter"` + Order types.String `tfsdk:"order"` + OrderBy types.String `tfsdk:"order_by"` + ImageShareGroups []producerimagesharegroup.DataSourceModel `tfsdk:"image_share_groups"` +} + +func (model *ImageShareGroupFilterModel) ParseImageShareGroups( + sgs []linodego.ProducerImageShareGroup, +) { + sgModels := make([]producerimagesharegroup.DataSourceModel, len(sgs)) + + for i, sg := range sgs { + var sgModel producerimagesharegroup.DataSourceModel + sgModel.ID = types.Int64Value(int64(sg.ID)) + sgModel.ParseImageShareGroup(&sg) + sgModels[i] = sgModel + + } + + model.ImageShareGroups = sgModels +} diff --git a/linode/producerimagesharegroups/framework_schema_datasource.go b/linode/producerimagesharegroups/framework_schema_datasource.go new file mode 100644 index 000000000..e9cac8692 --- /dev/null +++ b/linode/producerimagesharegroups/framework_schema_datasource.go @@ -0,0 +1,33 @@ +package producerimagesharegroups + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroup" +) + +var filterConfig = frameworkfilter.Config{ + "id": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeInt}, + "label": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "is_suspended": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeBool}, +} + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The data source's unique ID.", + Computed: true, + }, + "order": filterConfig.OrderSchema(), + "order_by": filterConfig.OrderBySchema(), + }, + Blocks: map[string]schema.Block{ + "filter": filterConfig.Schema(), + "image_share_groups": schema.ListNestedBlock{ + Description: "The returned list of Image SHare Groups.", + NestedObject: schema.NestedBlockObject{ + Attributes: producerimagesharegroup.Attributes, + }, + }, + }, +} diff --git a/linode/producerimagesharegroups/tmpl/data_basic.gotf b/linode/producerimagesharegroups/tmpl/data_basic.gotf new file mode 100644 index 000000000..8384d201b --- /dev/null +++ b/linode/producerimagesharegroups/tmpl/data_basic.gotf @@ -0,0 +1,54 @@ +{{ define "producer_image_share_groups_data_basic" }} + +resource "linode_producer_image_share_group" "test1" { + label = "{{ .Label1 }}" +} + +resource "linode_producer_image_share_group" "test2" { + label = "{{ .Label2 }}" +} + +data "linode_producer_image_share_groups" "all" { + depends_on = [ + linode_producer_image_share_group.test1, + linode_producer_image_share_group.test2, + ] +} + +data "linode_producer_image_share_groups" "by_label" { + depends_on = [ + linode_producer_image_share_group.test1, + linode_producer_image_share_group.test2, + ] + + filter { + name = "label" + values = [linode_producer_image_share_group.test1.label] + } +} + +data "linode_producer_image_share_groups" "by_id" { + depends_on = [ + linode_producer_image_share_group.test1, + linode_producer_image_share_group.test2, + ] + + filter { + name = "id" + values = [linode_producer_image_share_group.test2.id] + } +} + +data "linode_producer_image_share_groups" "by_is_suspended" { + depends_on = [ + linode_producer_image_share_group.test1, + linode_producer_image_share_group.test2, + ] + + filter { + name = "is_suspended" + values = ["false"] + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/producerimagesharegroups/tmpl/template.go b/linode/producerimagesharegroups/tmpl/template.go new file mode 100644 index 000000000..7be496277 --- /dev/null +++ b/linode/producerimagesharegroups/tmpl/template.go @@ -0,0 +1,20 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + Label1 string + Label2 string +} + +func DataBasic(t testing.TB, label1, label2 string) string { + return acceptance.ExecuteTemplate(t, + "producer_image_share_groups_data_basic", TemplateData{ + Label1: label1, + Label2: label2, + }) +} From f0411498e72053ee947184d4f344ff4472a12041 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Wed, 22 Oct 2025 14:26:53 -0400 Subject: [PATCH 08/29] Added datasource for Image Shares --- docs/data-sources/image.md | 2 + docs/data-sources/images.md | 2 + ...producer_image_share_group_image_shares.md | 93 ++++++++++++ linode/framework_provider.go | 2 + .../datasource_test.go | 58 ++++++++ .../framework_datasource.go | 98 +++++++++++++ .../framework_models.go | 136 ++++++++++++++++++ .../framework_schema_datasource.go | 130 +++++++++++++++++ .../tmpl/data_basic.gotf | 83 +++++++++++ .../tmpl/template.go | 28 ++++ .../framework_schema_datasource.go | 2 +- 11 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 docs/data-sources/producer_image_share_group_image_shares.md create mode 100644 linode/producerimagesharegroupimageshares/datasource_test.go create mode 100644 linode/producerimagesharegroupimageshares/framework_datasource.go create mode 100644 linode/producerimagesharegroupimageshares/framework_models.go create mode 100644 linode/producerimagesharegroupimageshares/framework_schema_datasource.go create mode 100644 linode/producerimagesharegroupimageshares/tmpl/data_basic.gotf create mode 100644 linode/producerimagesharegroupimageshares/tmpl/template.go diff --git a/docs/data-sources/image.md b/docs/data-sources/image.md index 25899d42f..788986b71 100644 --- a/docs/data-sources/image.md +++ b/docs/data-sources/image.md @@ -41,6 +41,8 @@ The Linode Image resource exports the following attributes: * `is_public` - True if the Image is public. +* `is_shared` - True if the Image is shared. + * `image_sharing` - Details about image sharing, including who the image is shared with and by. * `shared_with` - Details about who the image is shared with. * `sharegroup_count` - The number of sharegroups the private image is present in. diff --git a/docs/data-sources/images.md b/docs/data-sources/images.md index 138df055d..9f5433b70 100644 --- a/docs/data-sources/images.md +++ b/docs/data-sources/images.md @@ -79,6 +79,8 @@ Each Linode image will be stored in the `images` attribute and will export the f * `is_public` - True if the Image is public. +* `is_shared` - True if the Image is shared. + * `image_sharing` - Details about image sharing, including who the image is shared with and by. * `shared_with` - Details about who the image is shared with. * `sharegroup_count` - The number of sharegroups the private image is present in. diff --git a/docs/data-sources/producer_image_share_group_image_shares.md b/docs/data-sources/producer_image_share_group_image_shares.md new file mode 100644 index 000000000..bd5a55887 --- /dev/null +++ b/docs/data-sources/producer_image_share_group_image_shares.md @@ -0,0 +1,93 @@ +--- +page_title: "Linode: linode_producer_image_share_group_image_shares" +description: |- + Lists Images shared in the specified Image Share Group on your account. +--- + +# Data Source: linode\producer\_image\_share\_group\_image\_shares + +Provides information about a list of Images shared in the specified Image Share Group that match a set of filters. +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +The following example shows how one might use this data source to list Images shared in an Image Share Group. + +```hcl +data "linode_producer_image_share_group_image_shares" "all" {} + +data "linode_producer_image_share_group_image_shares" "filtered" { + filter { + name = "label" + values = ["my-label"] + } +} + +output "all-shared-images" { + value = data.linode_producer_image_share_group_image_shares.all.image_shares +} + +output "filtered-shared-images" { + value = data.linode_producer_image_share_group_image_shares.filtered.image_shares +} +``` + +## Argument Reference + +The following arguments are supported: + +* `sharegroup_id` - (Required) The ID of the Image Share Group to list shared Images from. + +* [`filter`](#filter) - (Optional) A set of filters used to select Image Share Groups that meet certain requirements. + +### Filter + +* `name` - (Required) The name of the field to filter by. See the [Filterable Fields section](#filterable-fields) for a complete list of filterable fields. + +* `values` - (Required) A list of values for the filter to allow. These values should all be in string form. + +* `match_by` - (Optional) The method to match the field by. (`exact`, `regex`, `substring`; default `exact`) + +## Attributes Reference + +Each Image Share will be stored in the `images_shares` attribute and will export the following attributes: + +* `id` - The unique ID assigned to this Image Share. + +* `label` - The label of the Image Share. + +* `capabilities` - The capabilities of the Image represented by the Image Share. + +* `created` - When this Image Share was created. + +* `deprecated` - Whether this Image is deprecated. + +* `description` - A description of the Image Share. + +* `is_public` - True if the Image is public. + +* `image_sharing` - Details about image sharing, including who the image is shared with and by. + * `shared_with` - Details about who the image is shared with. + * `sharegroup_count` - The number of sharegroups the private image is present in. + * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. + * `shared_by` - Details about who the image is shared by. + * `sharegroup_id` - The sharegroup_id from the im_ImageShare row. + * `sharegroup_uuid` - The sharegroup_uuid from the im_ImageShare row. + * `sharegroup_label` - The label from the associated im_ImageShareGroup row. + * `source_image_id` - The image id of the base image (will only be shown to producers, will be null for consumers). + +* `size` - The minimum size this Image needs to deploy. Size is in MB. example: 2500 + +* `status` - The current status of this image. (`creating`, `pending_upload`, `available`) + +* `type` - How the Image was created. Manual Images can be created at any time. "Automatic" Images are created automatically from a deleted Linode. (`manual`, `automatic`) + +* `tags` - A list of customized tags. + +* `total_size` - The total size of the image in all available regions. + +## Filterable Fields + +* `id` + +* `label` diff --git a/linode/framework_provider.go b/linode/framework_provider.go index fa8909373..fcd7d1083 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -76,6 +76,7 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/placementgroupassignment" "github.com/linode/terraform-provider-linode/v3/linode/placementgroups" "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroup" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupimageshares" "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupmember" "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroups" "github.com/linode/terraform-provider-linode/v3/linode/profile" @@ -339,5 +340,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource objquotas.NewDataSource, producerimagesharegroup.NewDataSource, producerimagesharegroups.NewDataSource, + producerimagesharegroupimageshares.NewDataSource, } } diff --git a/linode/producerimagesharegroupimageshares/datasource_test.go b/linode/producerimagesharegroupimageshares/datasource_test.go new file mode 100644 index 000000000..dfad2b19c --- /dev/null +++ b/linode/producerimagesharegroupimageshares/datasource_test.go @@ -0,0 +1,58 @@ +//go:build integration || producerimagesharegroupimageshares + +package producerimagesharegroupimageshares_test + +import ( + "log" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupimageshares/tmpl" +) + +func TestAccDataSourceImageShareGroupImageShares_basic(t *testing.T) { + t.Parallel() + + const dsAll = "data.linode_producer_image_share_group_image_shares.all" + const dsByID = "data.linode_producer_image_share_group_image_shares.by_id" + const dsByLabel = "data.linode_producer_image_share_group_image_shares.by_label" + + label := acctest.RandomWithPrefix("tf_test") + instanceLabel := acctest.RandomWithPrefix("tf_test") + + instanceRegion, err := acceptance.GetRandomRegionWithCaps([]string{}, "core") + if err != nil { + log.Fatal(err) + } + + imageLabel1 := acctest.RandomWithPrefix("tf-test") + imageLabel2 := acctest.RandomWithPrefix("tf-test") + shareGroupLabel := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, label, instanceLabel, instanceRegion, imageLabel1, imageLabel2, shareGroupLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dsAll, "image_shares.#", "2"), + resource.TestCheckResourceAttr(dsAll, "image_shares.0.label", "image_one_label"), + resource.TestCheckResourceAttr(dsAll, "image_shares.0.description", "image one description"), + resource.TestCheckResourceAttr(dsAll, "image_shares.1.label", "image_two_label"), + resource.TestCheckResourceAttr(dsAll, "image_shares.1.description", "image two description"), + + resource.TestCheckResourceAttr(dsByID, "image_shares.#", "1"), + resource.TestCheckResourceAttr(dsByID, "image_shares.0.label", "image_one_label"), + resource.TestCheckResourceAttr(dsByID, "image_shares.0.description", "image one description"), + + resource.TestCheckResourceAttr(dsByLabel, "image_shares.#", "1"), + resource.TestCheckResourceAttr(dsByLabel, "image_shares.0.label", "image_two_label"), + resource.TestCheckResourceAttr(dsByLabel, "image_shares.0.description", "image two description"), + ), + }, + }, + }) +} diff --git a/linode/producerimagesharegroupimageshares/framework_datasource.go b/linode/producerimagesharegroupimageshares/framework_datasource.go new file mode 100644 index 000000000..cb4ad7e94 --- /dev/null +++ b/linode/producerimagesharegroupimageshares/framework_datasource.go @@ -0,0 +1,98 @@ +package producerimagesharegroupimageshares + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_producer_image_share_group_image_shares", + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (r *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+r.Config.Name) + + var data ImageShareGroupImageShareFilterModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + id, d := filterConfig.GenerateID(data.Filters) + if d != nil { + resp.Diagnostics.Append(d) + return + } + data.ID = id + + shareGroupID := helper.FrameworkSafeInt64ToInt(data.ShareGroupID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + result, d := filterConfig.GetAndFilter( + ctx, + r.Meta.Client, + data.Filters, + listWrapper(shareGroupID), + data.Order, + data.OrderBy, + ) + if d != nil { + resp.Diagnostics.Append(d) + return + } + + data.parseImageShares(ctx, helper.AnySliceToTyped[linodego.ImageShareEntry](result)) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func listWrapper( + shareGroupID int, +) frameworkfilter.ListFunc { + return func( + ctx context.Context, + client *linodego.Client, + filter string, + ) ([]any, error) { + tflog.Trace(ctx, "client.ImageShareGroupListImageShareEntries(...)") + + nbs, err := client.ImageShareGroupListImageShareEntries( + ctx, + shareGroupID, + &linodego.ListOptions{ + Filter: filter, + }, + ) + if err != nil { + return nil, err + } + + return helper.TypedSliceToAny(nbs), nil + } +} diff --git a/linode/producerimagesharegroupimageshares/framework_models.go b/linode/producerimagesharegroupimageshares/framework_models.go new file mode 100644 index 000000000..d4ccab35a --- /dev/null +++ b/linode/producerimagesharegroupimageshares/framework_models.go @@ -0,0 +1,136 @@ +package producerimagesharegroupimageshares + +import ( + "context" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +type DataSourceModel struct { + ID types.String `tfsdk:"id"` + Label types.String `tfsdk:"label"` + Description types.String `tfsdk:"description"` + Capabilities []types.String `tfsdk:"capabilities"` + Created types.String `tfsdk:"created"` + Deprecated types.Bool `tfsdk:"deprecated"` + IsPublic types.Bool `tfsdk:"is_public"` + ImageSharing *ImageSharingDataSourceModel `tfsdk:"image_sharing"` + Size types.Int64 `tfsdk:"size"` + Status types.String `tfsdk:"status"` + Type types.String `tfsdk:"type"` + Tags types.List `tfsdk:"tags"` + TotalSize types.Int64 `tfsdk:"total_size"` +} + +type ImageSharingDataSourceModel struct { + SharedWith *ImageSharingSharedWithAttributesModel `tfsdk:"shared_with"` + SharedBy *ImageSharingSharedByAttributesModel `tfsdk:"shared_by"` +} + +type ImageSharingSharedWithAttributesModel struct { + ShareGroupCount types.Int64 `tfsdk:"sharegroup_count"` + ShareGroupListURL types.String `tfsdk:"sharegroup_list_url"` +} + +type ImageSharingSharedByAttributesModel struct { + ShareGroupID types.Int64 `tfsdk:"sharegroup_id"` + ShareGroupUUID types.String `tfsdk:"sharegroup_uuid"` + ShareGroupLabel types.String `tfsdk:"sharegroup_label"` + SourceImageID types.String `tfsdk:"source_image_id"` +} + +func (data *DataSourceModel) ParseImageShare( + ctx context.Context, + imageShare *linodego.ImageShareEntry, +) diag.Diagnostics { + data.ID = types.StringValue(imageShare.ID) + data.Label = types.StringValue(imageShare.Label) + + data.Description = types.StringValue(imageShare.Description) + if imageShare.Created != nil { + data.Created = types.StringValue(imageShare.Created.Format(time.RFC3339)) + } else { + data.Created = types.StringNull() + } + data.Capabilities = helper.StringSliceToFramework(imageShare.Capabilities) + data.Deprecated = types.BoolValue(imageShare.Deprecated) + data.IsPublic = types.BoolValue(imageShare.IsPublic) + data.Size = types.Int64Value(int64(imageShare.Size)) + data.Status = types.StringValue(string(imageShare.Status)) + data.Type = types.StringValue(imageShare.Type) + data.TotalSize = types.Int64Value(int64(imageShare.TotalSize)) + + tags, diags := types.ListValueFrom(ctx, types.StringType, imageShare.Tags) + if diags.HasError() { + return diags + } + data.Tags = tags + + data.ImageSharing = parseImageSharingDataSourceModel(&imageShare.ImageSharing) + + return nil +} + +func parseImageSharingDataSourceModel( + imageSharing *linodego.ImageSharing, +) *ImageSharingDataSourceModel { + if imageSharing == nil { + return nil + } + + var sharedWith *ImageSharingSharedWithAttributesModel + if sw := imageSharing.SharedWith; sw != nil { + sharedWith = &ImageSharingSharedWithAttributesModel{ + ShareGroupCount: types.Int64Value(int64(sw.ShareGroupCount)), + ShareGroupListURL: types.StringValue(sw.ShareGroupListURL), + } + } + + var sharedBy *ImageSharingSharedByAttributesModel + if sb := imageSharing.SharedBy; sb != nil { + sharedBy = &ImageSharingSharedByAttributesModel{ + ShareGroupID: types.Int64Value(int64(sb.ShareGroupID)), + ShareGroupUUID: types.StringValue(sb.ShareGroupUUID), + ShareGroupLabel: types.StringValue(sb.ShareGroupLabel), + SourceImageID: types.StringPointerValue(sb.SourceImageID), + } + } + + return &ImageSharingDataSourceModel{ + SharedWith: sharedWith, + SharedBy: sharedBy, + } +} + +type ImageShareGroupImageShareFilterModel struct { + ID types.String `tfsdk:"id"` + ShareGroupID types.Int64 `tfsdk:"sharegroup_id"` + Filters frameworkfilter.FiltersModelType `tfsdk:"filter"` + Order types.String `tfsdk:"order"` + OrderBy types.String `tfsdk:"order_by"` + ImageShares []DataSourceModel `tfsdk:"image_shares"` +} + +func (data *ImageShareGroupImageShareFilterModel) parseImageShares( + ctx context.Context, + imageShares []linodego.ImageShareEntry, +) diag.Diagnostics { + result := make([]DataSourceModel, len(imageShares)) + for i := range imageShares { + var imgShareData DataSourceModel + diags := imgShareData.ParseImageShare(ctx, &imageShares[i]) + if diags.HasError() { + return diags + } + result[i] = imgShareData + } + + data.ImageShares = result + + return nil +} diff --git a/linode/producerimagesharegroupimageshares/framework_schema_datasource.go b/linode/producerimagesharegroupimageshares/framework_schema_datasource.go new file mode 100644 index 000000000..6c0467d14 --- /dev/null +++ b/linode/producerimagesharegroupimageshares/framework_schema_datasource.go @@ -0,0 +1,130 @@ +package producerimagesharegroupimageshares + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +var filterConfig = frameworkfilter.Config{ + "id": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeString}, + "label": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeString}, +} + +var Attributes = map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique ID assigned to this Image Share.", + Computed: true, + }, + "label": schema.StringAttribute{ + Description: "The label of the Image Share.", + Computed: true, + }, + "capabilities": schema.SetAttribute{ + Description: "The capabilities of the Image represented by the Image Share.", + ElementType: types.StringType, + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "A description of the Image Share.", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "When this Image Share was created.", + Computed: true, + }, + "deprecated": schema.BoolAttribute{ + Description: "Whether or not this Image is deprecated.", + Computed: true, + }, + "is_public": schema.BoolAttribute{ + Description: "True if the Image is public.", + Computed: true, + }, + "image_sharing": schema.SingleNestedAttribute{ + Description: "Details about image sharing, including who the image is shared with and by.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "shared_with": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "sharegroup_count": schema.Int64Attribute{ + Description: "The number of sharegroups the private image is present in.", + Computed: true, + }, + "sharegroup_list_url": schema.StringAttribute{ + Description: "The GET api url to view the sharegroups in which the image is shared.", + Computed: true, + }, + }, + }, + "shared_by": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "sharegroup_id": schema.Int64Attribute{ + Description: "The sharegroup_id from the im_ImageShare row.", + Computed: true, + }, + "sharegroup_uuid": schema.StringAttribute{ + Description: "The sharegroup_uuid from the im_ImageShare row.", + Computed: true, + }, + "sharegroup_label": schema.StringAttribute{ + Description: "The label from the associated im_ImageShareGroup row.", + Computed: true, + }, + "source_image_id": schema.StringAttribute{ + Description: "The image id of the base image (will only be shown to producers, will be None for consumers).", + Computed: true, + }, + }, + }, + }, + }, + "size": schema.Int64Attribute{ + Description: "The minimum size this Image needs to deploy. Size is in MB.", + Computed: true, + }, + "status": schema.StringAttribute{ + Description: "The current status of this Image.", + Computed: true, + }, + "type": schema.StringAttribute{ + Description: "How the Image was created. 'Manual' Images can be created at any time. 'Automatic' " + + "images are created automatically from a deleted Linode.", + Computed: true, + }, + "tags": schema.ListAttribute{ + Description: "The customized tags for the image.", + Computed: true, + ElementType: types.StringType, + }, + "total_size": schema.Int64Attribute{ + Description: "The total size of the image in all available regions.", + Computed: true, + }, +} + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The data source's unique ID.", + Computed: true, + }, + "sharegroup_id": schema.Int64Attribute{ + Description: "The ID of the Image Share Group to list Image Shares for.", + Required: true, + }, + "order": filterConfig.OrderSchema(), + "order_by": filterConfig.OrderBySchema(), + }, + Blocks: map[string]schema.Block{ + "filter": filterConfig.Schema(), + "image_shares": schema.ListNestedBlock{ + Description: "The returned list of Image Shares.", + NestedObject: schema.NestedBlockObject{ + Attributes: Attributes, + }, + }, + }, +} diff --git a/linode/producerimagesharegroupimageshares/tmpl/data_basic.gotf b/linode/producerimagesharegroupimageshares/tmpl/data_basic.gotf new file mode 100644 index 000000000..4b62515f0 --- /dev/null +++ b/linode/producerimagesharegroupimageshares/tmpl/data_basic.gotf @@ -0,0 +1,83 @@ +{{ define "producer_image_share_group_image_shares_data_basic" }} + +{{ template "e2e_test_firewall" . }} + +resource "linode_instance" "foobar" { + label = "{{ .InstanceLabel }}" + group = "tf_test" + type = "g6-standard-1" + region = "{{ .InstanceRegion }}" + + disk { + label = "disk" + size = 1000 + filesystem = "ext4" + } + + firewall_id = linode_firewall.e2e_test_firewall.id +} + +resource "linode_image" "image_one" { + depends_on = [linode_instance.foobar] + linode_id = linode_instance.foobar.id + disk_id = linode_instance.foobar.disk.0.id + label = "{{ .ImageLabel1 }}" + description = "descriptive text image one" +} + +resource "linode_image" "image_two" { + depends_on = [linode_instance.foobar] + linode_id = linode_instance.foobar.id + disk_id = linode_instance.foobar.disk.0.id + label = "{{ .ImageLabel2 }}" + description = "descriptive text image two" +} + +resource "linode_producer_image_share_group" "foobar" { + label = "{{ .ShareGroupLabel }}" + description = "Example description" + images = [ + { + id = linode_image.image_one.id + description = "image one description" + label = "image_one_label" + }, + { + id = linode_image.image_two.id + description = "image two description" + label = "image_two_label" + } + ] +} + +data "linode_producer_image_share_group_image_shares" "all" { + depends_on = [linode_producer_image_share_group.foobar] + sharegroup_id = linode_producer_image_share_group.foobar.id +} + +locals { + first_image_share_id = try( + data.linode_producer_image_share_group_image_shares.all.image_shares[0].id, + null + ) +} + +data "linode_producer_image_share_group_image_shares" "by_id" { + sharegroup_id = linode_producer_image_share_group.foobar.id + + filter { + name = "id" + values = [local.first_image_share_id] + } +} + +data "linode_producer_image_share_group_image_shares" "by_label" { + sharegroup_id = linode_producer_image_share_group.foobar.id + + filter { + name = "label" + values = ["image_two_label"] + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/producerimagesharegroupimageshares/tmpl/template.go b/linode/producerimagesharegroupimageshares/tmpl/template.go new file mode 100644 index 000000000..bca0ac178 --- /dev/null +++ b/linode/producerimagesharegroupimageshares/tmpl/template.go @@ -0,0 +1,28 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + Label string + InstanceLabel string + InstanceRegion string + ImageLabel1 string + ImageLabel2 string + ShareGroupLabel string +} + +func DataBasic(t testing.TB, label, instanceLabel, instanceRegion, imageLabel1, imageLabel2, shareGroupLabel string) string { + return acceptance.ExecuteTemplate(t, + "producer_image_share_group_image_shares_data_basic", TemplateData{ + Label: label, + InstanceLabel: instanceLabel, + InstanceRegion: instanceRegion, + ImageLabel1: imageLabel1, + ImageLabel2: imageLabel2, + ShareGroupLabel: shareGroupLabel, + }) +} diff --git a/linode/producerimagesharegroups/framework_schema_datasource.go b/linode/producerimagesharegroups/framework_schema_datasource.go index e9cac8692..39a5b613a 100644 --- a/linode/producerimagesharegroups/framework_schema_datasource.go +++ b/linode/producerimagesharegroups/framework_schema_datasource.go @@ -24,7 +24,7 @@ var frameworkDataSourceSchema = schema.Schema{ Blocks: map[string]schema.Block{ "filter": filterConfig.Schema(), "image_share_groups": schema.ListNestedBlock{ - Description: "The returned list of Image SHare Groups.", + Description: "The returned list of Image Share Groups.", NestedObject: schema.NestedBlockObject{ Attributes: producerimagesharegroup.Attributes, }, From 205a9d5b720ce59b7fb2ae0835a3134b6c5e0a3f Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:33:39 -0400 Subject: [PATCH 09/29] VPC Dual Stack: Add support for IPv6 VPC in linode_interface resource (#2096) * VPC Dual Stack: Add support for IPv6 VPC in linode_interface resource * fix ordering * Update docs/data-sources/vpc_subnets.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/data-sources/vpc_subnet.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add validation to VPC and Subnet creation to prevent unexpected errors without VPC IPv6 enrollment * minor rework * oops --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/data-sources/vpc_subnet.md | 9 +- docs/data-sources/vpc_subnets.md | 9 +- docs/resources/interface.md | 68 +++++- linode/linodeinterface/framework_models.go | 3 + linode/linodeinterface/framework_resource.go | 2 +- .../framework_resource_schema.go | 96 +++++++++ .../framework_resource_test.go | 118 +++++++++++ .../linodeinterface/framework_vpc_models.go | 200 +++++++++++++++++- .../tmpl/public_updated_ipv4.gotf | 39 ++++ linode/linodeinterface/tmpl/template.go | 28 +++ linode/linodeinterface/tmpl/vpc_basic.gotf | 2 +- .../linodeinterface/tmpl/vpc_default_ip.gotf | 2 +- .../tmpl/vpc_empty_ip_objects.gotf | 2 +- .../tmpl/vpc_updated_ipv4.gotf | 47 ++++ .../linodeinterface/tmpl/vpc_with_ipv4.gotf | 2 +- .../linodeinterface/tmpl/vpc_with_ipv6_0.gotf | 58 +++++ .../linodeinterface/tmpl/vpc_with_ipv6_1.gotf | 62 ++++++ linode/nb/tmpl/vpc.gotf | 11 + linode/vpc/framework_resource.go | 12 ++ linode/vpcsubnet/framework_resource.go | 11 + 20 files changed, 757 insertions(+), 24 deletions(-) create mode 100644 linode/linodeinterface/tmpl/public_updated_ipv4.gotf create mode 100644 linode/linodeinterface/tmpl/vpc_updated_ipv4.gotf create mode 100644 linode/linodeinterface/tmpl/vpc_with_ipv6_0.gotf create mode 100644 linode/linodeinterface/tmpl/vpc_with_ipv6_1.gotf diff --git a/docs/data-sources/vpc_subnet.md b/docs/data-sources/vpc_subnet.md index 9356608ad..8ea3b969f 100644 --- a/docs/data-sources/vpc_subnet.md +++ b/docs/data-sources/vpc_subnet.md @@ -40,14 +40,19 @@ In addition to all arguments above, the following attributes are exported: * `ipv4` - The IPv4 range of this subnet in CIDR format. +* [`ipv6`](#ipv6) - A list of IPv6 ranges under this subnet. + * `linodes` - A list of Linodes added to this subnet. + * `id` - ID of the Linode + * `interfaces` - A list of networking interfaces objects. + * `id` - ID of the interface. + * `config_id` - ID of Linode Config that the interface is associated with. `null` for a Linode Interface. - * `active` - Whether the Interface is actively in use. -* [`ipv6`](#ipv6) - A list of IPv6 ranges under this subnet. + * `active` - Whether the Interface is actively in use. * `created` - The date and time when the VPC Subnet was created. diff --git a/docs/data-sources/vpc_subnets.md b/docs/data-sources/vpc_subnets.md index 8fa56579a..851668219 100644 --- a/docs/data-sources/vpc_subnets.md +++ b/docs/data-sources/vpc_subnets.md @@ -53,14 +53,19 @@ Each Linode VPC subnet will be stored in the `vpc_subnets` attribute and will ex * `ipv4` - The IPv4 range of this subnet in CIDR format. +* [`ipv6`](#ipv6) - A list of IPv6 ranges under this subnet. + * `linodes` - A list of Linodes added to this subnet. + * `id` - ID of the Linode + * `interfaces` - A list of networking interfaces objects. + * `id` - ID of the interface. + * `config_id` - ID of Linode Config that the interface is associated with. `null` for a Linode Interface. - * `active` - Whether the Interface is actively in use. -* [`ipv6`](#ipv6) - A list of IPv6 ranges under this subnet. + * `active` - Whether the Interface is actively in use. * `created` - The date and time when the VPC Subnet was created. diff --git a/docs/resources/interface.md b/docs/resources/interface.md index 259f7905c..5847f4320 100644 --- a/docs/resources/interface.md +++ b/docs/resources/interface.md @@ -93,6 +93,36 @@ resource "linode_interface" "vpc" { } ``` +### VPC (IPv6) Interface Example + +The following example shows how to create a public VPC interface with a custom IPv6 configuration. + +```hcl +resource "linode_interface" "vpc" { + linode_id = linode_instance.my-instance.id + + vpc = { + subnet_id = 12345 + + ipv6 = { + is_public = true + + slaac = [ + { + range = "auto" + } + ] + + ranges = [ + { + range = "auto" + } + ] + } + } +} +``` + ### VLAN Interface Example The following example shows how to create a VLAN interface. @@ -174,15 +204,15 @@ The following arguments are supported: * `firewall_id` - (Optional) The ID of an enabled firewall to secure a VPC or public interface. Not allowed for VLAN interfaces. -* `default_route` - (Optional) Indicates whether the interface serves as the default route when multiple interfaces are eligible for this role. +* `default_route` - (Optional) Indicates if the interface serves as the default route when multiple interfaces are eligible for this role. - * `ipv4` - (Optional) When set to true, the interface is used for the IPv4 default route. + * `ipv4` - (Optional) If set to true, the interface is used for the IPv4 default route. - * `ipv6` - (Optional) When set to true, the interface is used for the IPv6 default route. + * `ipv6` - (Optional) If set to true, the interface is used for the IPv6 default route. -* `public` - (Optional) Configuration for a Linode public interface. Exactly one of `public`, `vlan`, or `vpc` must be specified. +* `public` - (Optional) Nested attributes object for a Linode public interface. Exactly one of `public`, `vlan`, or `vpc` must be specified. - * `ipv4` - (Optional) IPv4 configuration for this interface. + * `ipv4` - (Optional) IPv4 addresses for this interface. * `addresses` - (Optional) IPv4 addresses configured for this Linode interface. Each object in this list supports: @@ -190,9 +220,9 @@ The following arguments are supported: * `primary` - (Optional) Whether this address is the primary address for the interface. - * `ipv6` - (Optional) IPv6 configuration for this interface. + * `ipv6` - (Optional) IPv6 addresses for this interface. - * `ranges` - (Optional) IPv6 ranges in CIDR notation (2600:0db8::1/64) or prefix-only (/64). Each object in this list supports: + * `ranges` - (Optional) Configured IPv6 range in CIDR notation (2600:0db8::1/64) or prefix-only (/64). Each object in this list supports: * `range` - (Required) The IPv6 range. @@ -222,6 +252,18 @@ The following arguments are supported: * `range` - (Required) The IPv4 range. + * `ipv6` - (Optional) IPv6 assigned through `slaac` and `ranges`. If you create a VPC interface in a subnet with IPv6 and don’t specify `slaac` or `ranges`, a SLAAC range is added automatically. **NOTE: IPv6 VPCs may not currently be available to all users.** + + * `is_public` - (Optional) Indicates whether the IPv6 configuration profile interface is public. (Default `false`) + + * `slaac` - (Optional) Defines IPv6 SLAAC address ranges. An address is automatically generated from the assigned /64 prefix using the Linode’s MAC address, just like on public IPv6 interfaces. Router advertisements (RA) are sent to the Linode, so standard SLAAC configuration works without any changes. + + * `range` - (Optional) The IPv6 network range in CIDR notation. + + * `ranges` - (Optional) Defines additional IPv6 network ranges. + + * `range` - (Optional) The IPv6 network range in CIDR notation. + ## Attributes Reference In addition to all arguments above, the following attributes are exported: @@ -280,12 +322,22 @@ In addition to all arguments above, the following attributes are exported: * `range` - The assigned IPv4 range. + * `ipv6` - IPv6 assigned through `slaac` and `ranges`. **NOTE: IPv6 VPCs may not currently be available to all users.** + + * `assigned_slaac` - Assigned IPv6 SLAAC address ranges to use in the VPC subnet, calculated from `slaac` input. + + * `range` - The IPv6 network range in CIDR notation. + + * `assigned_ranges` - Assigned additional IPv6 ranges to use in the VPC subnet, calculated from `ranges` input. + + * `range` - The IPv6 network range in CIDR notation. + ## Import Interfaces can be imported using a Linode ID followed by an Interface ID, separated by a comma, e.g. ```sh -terraform import linode_interface.example 67890,12345 +terraform import linode_interface.example 12345,67890 ``` ## Notes diff --git a/linode/linodeinterface/framework_models.go b/linode/linodeinterface/framework_models.go index c3822a315..d8b659d64 100644 --- a/linode/linodeinterface/framework_models.go +++ b/linode/linodeinterface/framework_models.go @@ -203,6 +203,9 @@ func (data *LinodeInterfaceModel) FlattenInterface( vpc.IPv4 = helper.KeepOrUpdateValue( vpc.IPv4, types.ObjectNull(vpcIPv4Attribute.GetType().(types.ObjectType).AttrTypes), pk, ) + vpc.IPv6 = helper.KeepOrUpdateValue( + vpc.IPv6, types.ObjectNull(vpcIPv6Attribute.GetType().(types.ObjectType).AttrTypes), pk, + ) vpc.SubnetID = helper.KeepOrUpdateValue(vpc.SubnetID, types.Int64Null(), pk) return } diff --git a/linode/linodeinterface/framework_resource.go b/linode/linodeinterface/framework_resource.go index ff2d54bd8..f2e190265 100644 --- a/linode/linodeinterface/framework_resource.go +++ b/linode/linodeinterface/framework_resource.go @@ -250,7 +250,7 @@ func (r *Resource) ImportState( func populateLogAttributes(ctx context.Context, model LinodeInterfaceModel) context.Context { return helper.SetLogFieldBulk(ctx, map[string]any{ - "linode_id": model.LinodeID.ValueInt64(), "id": model.ID.ValueString(), + "linode_id": model.LinodeID.ValueInt64(), }) } diff --git a/linode/linodeinterface/framework_resource_schema.go b/linode/linodeinterface/framework_resource_schema.go index f69bec297..c4f19c039 100644 --- a/linode/linodeinterface/framework_resource_schema.go +++ b/linode/linodeinterface/framework_resource_schema.go @@ -145,6 +145,50 @@ var computedVPCInterfaceIPv4Range = schema.NestedAttributeObject{ }, } +var configuredVPCInterfaceIPv6SLAAC = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "range": schema.StringAttribute{ + Description: "The IPv6 network range in CIDR notation.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("auto"), + }, + }, +} + +var computedVPCInterfaceIPv6SLAAC = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "range": schema.StringAttribute{ + Description: "The IPv6 network range in CIDR notation.", + Computed: true, + }, + "address": schema.StringAttribute{ + Description: "The assigned IPv6 address within the range.", + Computed: true, + }, + }, +} + +var configuredVPCInterfaceIPv6Range = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "range": schema.StringAttribute{ + Description: "The IPv6 network range in CIDR notation.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("auto"), + }, + }, +} + +var computedVPCInterfaceIPv6Range = schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "range": schema.StringAttribute{ + Description: "The IPv6 network range in CIDR notation.", + Computed: true, + }, + }, +} + var publicIPv4Attribute = schema.SingleNestedAttribute{ Description: "IPv4 addresses for this interface.", Optional: true, @@ -289,6 +333,57 @@ var vpcIPv4Attribute = schema.SingleNestedAttribute{ }, } +var vpcIPv6Attribute = schema.SingleNestedAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "is_public": schema.BoolAttribute{ + Description: "Indicates whether the IPv6 configuration on the Linode interface is public.", + Optional: true, + Computed: true, + }, + "slaac": schema.ListNestedAttribute{ + Description: "Defines IPv6 SLAAC address ranges.", + Optional: true, + NestedObject: configuredVPCInterfaceIPv6SLAAC, + Validators: []validator.List{ + listvalidator.NoNullValues(), + }, + }, + "assigned_slaac": schema.SetNestedAttribute{ + Description: "Assigned IPv6 SLAAC address ranges, calculated from `addresses` input.", + Computed: true, + NestedObject: computedVPCInterfaceIPv6SLAAC, + PlanModifiers: []planmodifier.Set{ + linodesetplanmodifier.UseStateForUnknownUnlessTheseChanged( + path.MatchRoot("vpc").AtName("ipv6").AtName("slaac"), + ), + }, + }, + "ranges": schema.ListNestedAttribute{ + Description: "CIDR notation of a range (1.2.3.4/24) or prefix only (/24).", + Optional: true, + NestedObject: configuredVPCInterfaceIPv6Range, + Validators: []validator.List{ + listvalidator.NoNullValues(), + }, + }, + "assigned_ranges": schema.SetNestedAttribute{ + Description: "Assigned IPv6 ranges to use in the VPC subnet, calculated from `ranges` input.", + Computed: true, + NestedObject: computedVPCInterfaceIPv6Range, + PlanModifiers: []planmodifier.Set{ + linodesetplanmodifier.UseStateForUnknownUnlessTheseChanged( + path.MatchRoot("vpc").AtName("ipv6").AtName("ranges"), + ), + }, + }, + }, +} + var vpcInterfaceSchema = schema.SingleNestedAttribute{ Description: "Linode VPC interface.", Optional: true, @@ -300,6 +395,7 @@ var vpcInterfaceSchema = schema.SingleNestedAttribute{ }, Attributes: map[string]schema.Attribute{ "ipv4": vpcIPv4Attribute, + "ipv6": vpcIPv6Attribute, "subnet_id": schema.Int64Attribute{ Required: true, Description: "The VPC subnet identifier for this interface.", diff --git a/linode/linodeinterface/framework_resource_test.go b/linode/linodeinterface/framework_resource_test.go index 7bb6fd089..8d0868649 100644 --- a/linode/linodeinterface/framework_resource_test.go +++ b/linode/linodeinterface/framework_resource_test.go @@ -815,6 +815,124 @@ func TestAccLinodeInterface_vpc_empty_ip_objects(t *testing.T) { }) } +func TestAccLinodeInterface_vpc_with_ipv6(t *testing.T) { + t.Parallel() + + targetRegion, err := acceptance.GetRandomRegionWithCaps([]string{ + linodego.CapabilityLinodes, + linodego.CapabilityVlans, + linodego.CapabilityVPCs, + "VPC Dual Stack", + }, "core") + if err != nil { + log.Fatal(err) + } + + label := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + CheckDestroy: checkInterfaceDestroy, + Steps: []resource.TestStep{ + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCWithIPv60(t, label, targetRegion, "10.0.0.0/24"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("subnet_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_slaac"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_slaac").AtSliceIndex(0).AtMapKey("range"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_slaac").AtSliceIndex(0).AtMapKey("address"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_ranges"), + knownvalue.ListSizeExact(2), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_ranges").AtSliceIndex(0).AtMapKey("range"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_ranges").AtSliceIndex(1).AtMapKey("range"), + knownvalue.NotNull(), + ), + }, + Check: checkInterfaceExists, + }, + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCWithIPv61(t, label, targetRegion, "10.0.0.0/24"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("linode_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testInterfaceResName, tfjsonpath.New("vpc").AtMapKey("subnet_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_slaac"), + knownvalue.ListSizeExact(1), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_slaac").AtSliceIndex(0).AtMapKey("range"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_slaac").AtSliceIndex(0).AtMapKey("address"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_ranges"), + knownvalue.ListSizeExact(3), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_ranges").AtSliceIndex(0).AtMapKey("range"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_ranges").AtSliceIndex(1).AtMapKey("range"), + knownvalue.NotNull(), + ), + statecheck.ExpectKnownValue( + testInterfaceResName, + tfjsonpath.New("vpc").AtMapKey("ipv6").AtMapKey("assigned_ranges").AtSliceIndex(2).AtMapKey("range"), + knownvalue.NotNull(), + ), + }, + Check: checkInterfaceExists, + }, + { + Config: linodeinstancetmpl.ProviderNoPoll(t) + tmpl.VPCWithIPv61(t, label, targetRegion, "10.0.0.0/24"), + ConfigStateChecks: []statecheck.StateCheck{}, + }, + { + ResourceName: testInterfaceResName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateID, + ImportStateVerifyIgnore: []string{"vpc.ipv6.ranges", "vpc.ipv6.slaac"}, + }, + }, + }) +} + func importStateID(s *terraform.State) (string, error) { for _, rs := range s.RootModule().Resources { if rs.Type != "linode_interface" { diff --git a/linode/linodeinterface/framework_vpc_models.go b/linode/linodeinterface/framework_vpc_models.go index 914b790b6..42af6f1ae 100644 --- a/linode/linodeinterface/framework_vpc_models.go +++ b/linode/linodeinterface/framework_vpc_models.go @@ -13,6 +13,7 @@ import ( type VPCAttrModel struct { IPv4 types.Object `tfsdk:"ipv4"` + IPv6 types.Object `tfsdk:"ipv6"` SubnetID types.Int64 `tfsdk:"subnet_id"` } @@ -37,6 +38,27 @@ type VPCIPv4RangeAttrModel struct { Range types.String `tfsdk:"range"` } +type VPCIPv6AttrModel struct { + IsPublic types.Bool `tfsdk:"is_public"` + SLAAC types.List `tfsdk:"slaac"` + AssignedSLAAC types.Set `tfsdk:"assigned_slaac"` + Ranges types.List `tfsdk:"ranges"` + AssignedRanges types.Set `tfsdk:"assigned_ranges"` +} + +type VPCIPv6SLAACAttrModel struct { + Range types.String `tfsdk:"range"` +} + +type VPCIPv6SLAACAttrComputedModel struct { + Range types.String `tfsdk:"range"` + Address types.String `tfsdk:"address"` +} + +type VPCIPv6RangeAttrModel struct { + Range types.String `tfsdk:"range"` +} + func (plan *VPCAttrModel) GetCreateOptions(ctx context.Context, diags *diag.Diagnostics) (opts linodego.VPCInterfaceCreateOptions) { tflog.Trace(ctx, "Enter VPCAttrModel.GetCreateOptions") @@ -44,11 +66,18 @@ func (plan *VPCAttrModel) GetCreateOptions(ctx context.Context, diags *diag.Diag if !plan.IPv4.IsUnknown() && !plan.IPv4.IsNull() { var planIPv4 VPCIPv4AttrModel - plan.IPv4.As(ctx, &planIPv4, basetypes.ObjectAsOptions{}) - ipv4Opts, _ := planIPv4.GetCreateOrUpdateOptions(ctx, nil) + diags.Append(plan.IPv4.As(ctx, &planIPv4, basetypes.ObjectAsOptions{})...) + ipv4Opts, _ := planIPv4.GetCreateOrUpdateOptions(ctx, nil, diags) opts.IPv4 = &ipv4Opts } + if !plan.IPv6.IsUnknown() && !plan.IPv6.IsNull() { + var planIPv6 VPCIPv6AttrModel + diags.Append(plan.IPv6.As(ctx, &planIPv6, basetypes.ObjectAsOptions{})...) + ipv6Opts, _ := planIPv6.GetCreateOrUpdateOptions(ctx, nil, diags) + opts.IPv6 = &ipv6Opts + } + return opts } @@ -63,31 +92,56 @@ func (plan *VPCAttrModel) GetUpdateOptions( if !plan.IPv4.IsUnknown() && !plan.IPv4.IsNull() { var planIPv4 VPCIPv4AttrModel - plan.IPv4.As(ctx, &planIPv4, basetypes.ObjectAsOptions{}) + diags.Append(plan.IPv4.As(ctx, &planIPv4, basetypes.ObjectAsOptions{})...) var stateIPv4 *VPCIPv4AttrModel if state != nil && !state.IPv4.IsNull() { - state.IPv4.As(ctx, &stateIPv4, basetypes.ObjectAsOptions{}) + diags.Append(state.IPv4.As(ctx, &stateIPv4, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return opts, shouldUpdate + } } - if ipv4Opts, ipv4ShouldUpdate := planIPv4.GetCreateOrUpdateOptions(ctx, stateIPv4); ipv4ShouldUpdate { + if ipv4Opts, ipv4ShouldUpdate := planIPv4.GetCreateOrUpdateOptions(ctx, stateIPv4, diags); ipv4ShouldUpdate { opts.IPv4 = &ipv4Opts shouldUpdate = true } } + if !plan.IPv6.IsUnknown() && !plan.IPv6.IsNull() { + var planIPv6 VPCIPv6AttrModel + diags.Append(plan.IPv6.As(ctx, &planIPv6, basetypes.ObjectAsOptions{})...) + + var stateIPv6 *VPCIPv6AttrModel + if state != nil && !state.IPv6.IsNull() { + diags.Append(state.IPv6.As(ctx, &stateIPv6, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return opts, shouldUpdate + } + } + + if ipv6Opts, ipv6ShouldUpdate := planIPv6.GetCreateOrUpdateOptions(ctx, stateIPv6, diags); ipv6ShouldUpdate { + opts.IPv6 = &ipv6Opts + shouldUpdate = true + } + } + return opts, shouldUpdate } func (plan *VPCIPv4AttrModel) GetCreateOrUpdateOptions( ctx context.Context, state *VPCIPv4AttrModel, + diags *diag.Diagnostics, ) (opts linodego.VPCInterfaceIPv4CreateOptions, shouldUpdate bool) { tflog.Trace(ctx, "Enter VPCIPv4AttrModel.GetCreateOrUpdateOptions") if !plan.Addresses.IsUnknown() && !plan.Addresses.IsNull() && (state == nil || !state.Addresses.Equal(plan.Addresses)) { length := len(plan.Addresses.Elements()) addresses := make([]VPCIPv4AddressAttrModel, 0, length) - plan.Addresses.ElementsAs(ctx, &addresses, false) + diags.Append(plan.Addresses.ElementsAs(ctx, &addresses, false)...) + if diags.HasError() { + return opts, shouldUpdate + } addressOpts := make([]linodego.VPCInterfaceIPv4AddressCreateOptions, len(addresses)) for i, address := range addresses { @@ -100,7 +154,10 @@ func (plan *VPCIPv4AttrModel) GetCreateOrUpdateOptions( if !plan.Ranges.IsUnknown() && !plan.Ranges.IsNull() && (state == nil || !state.Ranges.Equal(plan.Ranges)) { length := len(plan.Ranges.Elements()) ranges := make([]VPCIPv4RangeAttrModel, 0, length) - plan.Ranges.ElementsAs(ctx, &ranges, false) + diags.Append(plan.Ranges.ElementsAs(ctx, &ranges, false)...) + if diags.HasError() { + return opts, shouldUpdate + } rangeOpts := make([]linodego.VPCInterfaceIPv4RangeCreateOptions, len(ranges)) for i, r := range ranges { @@ -160,6 +217,19 @@ func (data *VPCAttrModel) FlattenVPCInterface( } data.IPv4 = *flattenedIPv4 + + flattenedIPv6 := helper.KeepOrUpdateSingleNestedAttributesWithTypes( + ctx, data.IPv6, vpcIPv6Attribute.GetType().(basetypes.ObjectType).AttrTypes, preserveKnown, diags, + func(ipv6 *VPCIPv6AttrModel, isNull *bool, pk bool, d *diag.Diagnostics) { + ipv6.FlattenVPCIPv6(ctx, vpcInterface.IPv6, pk, d) + }, + ) + + if diags.HasError() { + return + } + + data.IPv6 = *flattenedIPv6 } func (data *VPCIPv4AttrModel) FlattenVPCIPv4(ctx context.Context, ipv4 linodego.VPCInterfaceIPv4, preserveKnown bool, diags *diag.Diagnostics) { @@ -210,3 +280,119 @@ func (data *VPCIPv4AttrModel) FlattenVPCIPv4(ctx context.Context, ipv4 linodego. data.AssignedRanges = helper.KeepOrUpdateValue(data.AssignedRanges, assignedRangesValue, preserveKnown) } + +func (plan *VPCIPv6AttrModel) GetCreateOrUpdateOptions( + ctx context.Context, + state *VPCIPv6AttrModel, + diags *diag.Diagnostics, +) (opts linodego.VPCInterfaceIPv6CreateOptions, shouldUpdate bool) { + if !plan.IsPublic.IsUnknown() && + !plan.IsPublic.IsNull() && (state == nil || !state.IsPublic.Equal(plan.IsPublic)) { + opts.IsPublic = plan.IsPublic.ValueBoolPointer() + shouldUpdate = true + } + + if !plan.SLAAC.IsUnknown() && !plan.SLAAC.IsNull() && (state == nil || !state.SLAAC.Equal(plan.SLAAC)) { + length := len(plan.SLAAC.Elements()) + slaac := make([]VPCIPv6SLAACAttrModel, 0, length) + diags.Append(plan.SLAAC.ElementsAs(ctx, &slaac, false)...) + if diags.HasError() { + return opts, shouldUpdate + } + + slaacOpts := helper.MapSlice( + slaac, + func(entry VPCIPv6SLAACAttrModel) linodego.VPCInterfaceIPv6SLAACCreateOptions { + return entry.GetCreateOptions() + }, + ) + opts.SLAAC = &slaacOpts + shouldUpdate = true + } + + if !plan.Ranges.IsUnknown() && !plan.Ranges.IsNull() && (state == nil || !state.Ranges.Equal(plan.Ranges)) { + length := len(plan.Ranges.Elements()) + ranges := make([]VPCIPv6RangeAttrModel, 0, length) + diags.Append(plan.Ranges.ElementsAs(ctx, &ranges, false)...) + if diags.HasError() { + return opts, shouldUpdate + } + + rangeOpts := make([]linodego.VPCInterfaceIPv6RangeCreateOptions, len(ranges)) + for i, r := range ranges { + rangeOpts[i] = r.GetCreateOptions() + } + opts.Ranges = &rangeOpts + shouldUpdate = true + } + + return opts, shouldUpdate +} + +func (plan *VPCIPv6SLAACAttrModel) GetCreateOptions() linodego.VPCInterfaceIPv6SLAACCreateOptions { + opts := linodego.VPCInterfaceIPv6SLAACCreateOptions{} + + if !plan.Range.IsUnknown() { + opts.Range = plan.Range.ValueString() + } + + return opts +} + +func (plan *VPCIPv6RangeAttrModel) GetCreateOptions() linodego.VPCInterfaceIPv6RangeCreateOptions { + return linodego.VPCInterfaceIPv6RangeCreateOptions{ + Range: plan.Range.ValueString(), + } +} + +func (data *VPCIPv6AttrModel) FlattenVPCIPv6(ctx context.Context, ipv6 linodego.VPCInterfaceIPv6, preserveKnown bool, diags *diag.Diagnostics) { + data.IsPublic = helper.KeepOrUpdateValue(data.IsPublic, types.BoolPointerValue(ipv6.IsPublic), preserveKnown) + + // When the object is null/unknown, the types of attributes of the object won't be filled by object.As(...) in the + // helper function `KeepOrUpdateSingleNestedAttributeWithTypes`, so resetting manually here. + if data.SLAAC.IsNull() { + data.SLAAC = types.ListNull(configuredVPCInterfaceIPv6SLAAC.Type()) + } + if data.Ranges.IsNull() { + data.Ranges = types.ListNull(configuredVPCInterfaceIPv6Range.Type()) + } + + assignedSLAAC := helper.MapSlice( + ipv6.SLAAC, + func(slaac linodego.VPCInterfaceIPv6SLAAC) VPCIPv6SLAACAttrComputedModel { + return VPCIPv6SLAACAttrComputedModel{ + Range: types.StringValue(slaac.Range), + Address: types.StringValue(slaac.Address), + } + }, + ) + + assignedSLAACValue, assignedSLAACDiags := types.SetValueFrom( + ctx, computedVPCInterfaceIPv6SLAAC.GetAttributes().Type(), assignedSLAAC, + ) + diags.Append(assignedSLAACDiags...) + if diags.HasError() { + return + } + + data.AssignedSLAAC = helper.KeepOrUpdateValue(data.AssignedSLAAC, assignedSLAACValue, preserveKnown) + + assignedRanges := helper.MapSlice( + ipv6.Ranges, + func(r linodego.VPCInterfaceIPv6Range) VPCIPv6RangeAttrModel { + return VPCIPv6RangeAttrModel{ + Range: types.StringValue(r.Range), + } + }, + ) + + assignedRangesValue, assignedRangesDiags := types.SetValueFrom( + ctx, computedVPCInterfaceIPv6Range.GetAttributes().Type(), assignedRanges, + ) + diags.Append(assignedRangesDiags...) + if diags.HasError() { + return + } + + data.AssignedRanges = helper.KeepOrUpdateValue(data.AssignedRanges, assignedRangesValue, preserveKnown) +} diff --git a/linode/linodeinterface/tmpl/public_updated_ipv4.gotf b/linode/linodeinterface/tmpl/public_updated_ipv4.gotf new file mode 100644 index 000000000..5123dc6fe --- /dev/null +++ b/linode/linodeinterface/tmpl/public_updated_ipv4.gotf @@ -0,0 +1,39 @@ +{{ define "interface_public_updated_ipv4" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + public = { + ipv4 = { + addresses = [ + { + address = "auto" + primary = true + }, + { + address = "auto" + primary = false + } + ] + } + ipv6 = { + ranges = [ + { + range = "/64" + }, + { + range = "/64" + } + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/template.go b/linode/linodeinterface/tmpl/template.go index c8e788dd5..146255a44 100644 --- a/linode/linodeinterface/tmpl/template.go +++ b/linode/linodeinterface/tmpl/template.go @@ -99,6 +99,16 @@ func VPCDefaultIP(t testing.TB, label, region, subnetIPv4 string) string { Label: label, Region: region, SubnetIPv4: subnetIPv4, + }, + ) +} + +func VPCUpdatedIPv4(t testing.TB, label, region, ipv4 string) string { + return acceptance.ExecuteTemplate(t, + "interface_vpc_updated_ipv4", TemplateData{ + Label: label, + Region: region, + SubnetIPv4: ipv4, }) } @@ -143,3 +153,21 @@ func VPCDefaultRouteIPv4(t testing.TB, label, region, subnetIPv4 string) string SubnetIPv4: subnetIPv4, }) } + +func VPCWithIPv60(t testing.TB, label, region, subnetIPv4 string) string { + return acceptance.ExecuteTemplate(t, + "interface_vpc_with_ipv6_0", TemplateData{ + Label: label, + Region: region, + SubnetIPv4: subnetIPv4, + }) +} + +func VPCWithIPv61(t testing.TB, label, region, subnetIPv4 string) string { + return acceptance.ExecuteTemplate(t, + "interface_vpc_with_ipv6_1", TemplateData{ + Label: label, + Region: region, + SubnetIPv4: subnetIPv4, + }) +} diff --git a/linode/linodeinterface/tmpl/vpc_basic.gotf b/linode/linodeinterface/tmpl/vpc_basic.gotf index d2524d3a2..7c7c08c57 100644 --- a/linode/linodeinterface/tmpl/vpc_basic.gotf +++ b/linode/linodeinterface/tmpl/vpc_basic.gotf @@ -8,7 +8,7 @@ resource "linode_vpc" "test" { resource "linode_vpc_subnet" "test" { vpc_id = linode_vpc.test.id - label = "{{.Label}}-subnet" + label = "{{.Label}}-subnet" ipv4 = "{{.SubnetIPv4}}" } diff --git a/linode/linodeinterface/tmpl/vpc_default_ip.gotf b/linode/linodeinterface/tmpl/vpc_default_ip.gotf index 15289d943..c0903662b 100644 --- a/linode/linodeinterface/tmpl/vpc_default_ip.gotf +++ b/linode/linodeinterface/tmpl/vpc_default_ip.gotf @@ -8,7 +8,7 @@ resource "linode_vpc" "test" { resource "linode_vpc_subnet" "test" { vpc_id = linode_vpc.test.id - label = "{{.Label}}-subnet" + label = "{{.Label}}-subnet" ipv4 = "{{.SubnetIPv4}}" } diff --git a/linode/linodeinterface/tmpl/vpc_empty_ip_objects.gotf b/linode/linodeinterface/tmpl/vpc_empty_ip_objects.gotf index ddce49565..ee08b6aad 100644 --- a/linode/linodeinterface/tmpl/vpc_empty_ip_objects.gotf +++ b/linode/linodeinterface/tmpl/vpc_empty_ip_objects.gotf @@ -8,7 +8,7 @@ resource "linode_vpc" "test" { resource "linode_vpc_subnet" "test" { vpc_id = linode_vpc.test.id - label = "{{.Label}}-subnet" + label = "{{.Label}}-subnet" ipv4 = "{{.SubnetIPv4}}" } diff --git a/linode/linodeinterface/tmpl/vpc_updated_ipv4.gotf b/linode/linodeinterface/tmpl/vpc_updated_ipv4.gotf new file mode 100644 index 000000000..8fcefe518 --- /dev/null +++ b/linode/linodeinterface/tmpl/vpc_updated_ipv4.gotf @@ -0,0 +1,47 @@ +{{ define "interface_vpc_updated_ipv4" }} + +resource "linode_vpc" "test" { + label = "{{.Label}}-vpc" + region = "{{.Region}}" + description = "vpc for interface testing" +} + +resource "linode_vpc_subnet" "test" { + vpc_id = linode_vpc.test.id + label = "{{.Label}}-subnet" + ipv4 = "{{.SubnetIPv4}}" +} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + vpc = { + subnet_id = linode_vpc_subnet.test.id + ipv4 = { + addresses = [ + { + address = "auto" + primary = true + }, + { + address = "auto" + primary = false + } + ] + ranges = [ + { + range = "/32" + } + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/vpc_with_ipv4.gotf b/linode/linodeinterface/tmpl/vpc_with_ipv4.gotf index efd6deb9e..8d458614b 100644 --- a/linode/linodeinterface/tmpl/vpc_with_ipv4.gotf +++ b/linode/linodeinterface/tmpl/vpc_with_ipv4.gotf @@ -8,7 +8,7 @@ resource "linode_vpc" "test" { resource "linode_vpc_subnet" "test" { vpc_id = linode_vpc.test.id - label = "{{.Label}}-subnet" + label = "{{.Label}}-subnet" ipv4 = "{{.SubnetIPv4}}" } diff --git a/linode/linodeinterface/tmpl/vpc_with_ipv6_0.gotf b/linode/linodeinterface/tmpl/vpc_with_ipv6_0.gotf new file mode 100644 index 000000000..2042f12e2 --- /dev/null +++ b/linode/linodeinterface/tmpl/vpc_with_ipv6_0.gotf @@ -0,0 +1,58 @@ +{{ define "interface_vpc_with_ipv6_0" }} + +resource "linode_vpc" "test" { + label = "{{.Label}}-vpc" + region = "{{.Region}}" + + ipv6 = [ + { + range = "auto" + } + ] +} + +resource "linode_vpc_subnet" "test" { + vpc_id = linode_vpc.test.id + label = "{{.Label}}-subnet" + ipv4 = "{{.SubnetIPv4}}" + + ipv6 = [ + { + range = "auto" + } + ] +} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + vpc = { + subnet_id = linode_vpc_subnet.test.id + + ipv6 = { + is_public = false + + slaac = [ + { + range = "auto" + } + ] + + ranges = [ + { + range = "auto" + }, + {}, + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/linodeinterface/tmpl/vpc_with_ipv6_1.gotf b/linode/linodeinterface/tmpl/vpc_with_ipv6_1.gotf new file mode 100644 index 000000000..c1a3dcf63 --- /dev/null +++ b/linode/linodeinterface/tmpl/vpc_with_ipv6_1.gotf @@ -0,0 +1,62 @@ +{{ define "interface_vpc_with_ipv6_1" }} + +resource "linode_vpc" "test" { + label = "{{.Label}}-vpc" + region = "{{.Region}}" + + ipv6 = [ + { + range = "auto" + } + ] +} + +resource "linode_vpc_subnet" "test" { + vpc_id = linode_vpc.test.id + label = "{{.Label}}-subnet" + ipv4 = "{{.SubnetIPv4}}" + + ipv6 = [ + { + range = "auto" + } + ] +} + +resource "linode_instance" "test" { + label = "{{.Label}}" + region = "{{.Region}}" + type = "g6-nanode-1" + interface_generation = "linode" +} + +resource "linode_interface" "test" { + linode_id = linode_instance.test.id + + vpc = { + subnet_id = linode_vpc_subnet.test.id + ipv6 = { + is_public = true + + slaac = [ + { + range = "auto" + } + ] + + ranges = [ + { + range = "auto" + }, + { + range = "auto" + }, + { + range = "auto" + } + ] + } + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/nb/tmpl/vpc.gotf b/linode/nb/tmpl/vpc.gotf index 5b5eb5b0c..3f5215f80 100644 --- a/linode/nb/tmpl/vpc.gotf +++ b/linode/nb/tmpl/vpc.gotf @@ -11,6 +11,7 @@ resource "linode_nodebalancer" "test" { { subnet_id = linode_vpc_subnet.test.id ipv4_range = "10.0.0.4/30" + ipv6_range = "auto" ipv4_range_auto_assign = true } ] @@ -19,12 +20,22 @@ resource "linode_nodebalancer" "test" { resource "linode_vpc" "test" { label = "{{ .Label }}" region = "{{ .Region }}" + ipv6 = [ + { + range = "auto" + } + ] } resource "linode_vpc_subnet" "test" { vpc_id = linode_vpc.test.id label = "tf-test" ipv4 = "10.0.0.0/24" + ipv6 = [ + { + range = "auto" + } + ] } {{ end }} \ No newline at end of file diff --git a/linode/vpc/framework_resource.go b/linode/vpc/framework_resource.go index 824c590ee..fd0207278 100644 --- a/linode/vpc/framework_resource.go +++ b/linode/vpc/framework_resource.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" @@ -81,6 +82,8 @@ func (r *Resource) Create( return } + ipv6Configured := !data.IPv6.IsNull() + resp.Diagnostics.Append(data.FlattenVPC(ctx, vpc, true)...) if resp.Diagnostics.HasError() { return @@ -91,6 +94,15 @@ func (r *Resource) Create( data.ID = types.StringValue(strconv.Itoa(vpc.ID)) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + + if ipv6Configured && vpc.IPv6 == nil { + resp.Diagnostics.AddAttributeError( + path.Root("ipv6"), + "Value Mismatch", + "The `ipv6` field was configured but was not found in the API's response. "+ + "Please ensure the current user has access to the VPC IPv6 feature.", + ) + } } func (r *Resource) Read( diff --git a/linode/vpcsubnet/framework_resource.go b/linode/vpcsubnet/framework_resource.go index 844d52f3f..a4fd6aa2c 100644 --- a/linode/vpcsubnet/framework_resource.go +++ b/linode/vpcsubnet/framework_resource.go @@ -110,6 +110,8 @@ func (r *Resource) Create( return } + ipv6Configured := !data.IPv6.IsNull() + resp.Diagnostics.Append(data.FlattenSubnet(ctx, subnet, true)...) if resp.Diagnostics.HasError() { return @@ -120,6 +122,15 @@ func (r *Resource) Create( data.ID = types.StringValue(strconv.Itoa(subnet.ID)) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + + if ipv6Configured && subnet.IPv6 == nil { + resp.Diagnostics.AddAttributeError( + path.Root("ipv6"), + "Value Mismatch", + "The `ipv6` field was configured but was not found in the API's response. "+ + "Please ensure the current user has access to the VPC IPv6 feature.", + ) + } } func (r *Resource) Read( From 78ddd4940e657ed760f75bd78e41643215d2e3fa Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:02:22 -0400 Subject: [PATCH 10/29] Remove unnecessary sweep function (#2135) --- .../framework_resource_test.go | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/linode/linodeinterface/framework_resource_test.go b/linode/linodeinterface/framework_resource_test.go index 8d0868649..8d603fb75 100644 --- a/linode/linodeinterface/framework_resource_test.go +++ b/linode/linodeinterface/framework_resource_test.go @@ -27,11 +27,6 @@ const testInterfaceResName = "linode_interface.test" var testRegion string func init() { - resource.AddTestSweepers("linode_interface", &resource.Sweeper{ - Name: "linode_interface", - F: sweep, - }) - region, err := acceptance.GetRandomRegionWithCaps([]string{linodego.CapabilityLinodes, linodego.CapabilityVlans, linodego.CapabilityVPCs}, "core") if err != nil { log.Fatal(err) @@ -40,46 +35,6 @@ func init() { testRegion = region } -func sweep(prefix string) error { - client, err := acceptance.GetTestClient() - if err != nil { - return fmt.Errorf("failed to get client: %s", err) - } - - // Get all instances and sweep their interfaces - instances, err := client.ListInstances(context.Background(), nil) - if err != nil { - return fmt.Errorf("failed to get instances: %s", err) - } - - for _, instance := range instances { - if !acceptance.ShouldSweep(prefix, instance.Label) { - continue - } - - // Get instance configs to find interfaces - configs, err := client.ListInstanceConfigs(context.Background(), instance.ID, nil) - if err != nil { - continue // Skip if we can't get configs - } - - // Delete non-primary interfaces from configs - for _, config := range configs { - for i, iface := range config.Interfaces { - // Skip eth0 (primary interface) and other essential interfaces - if i == 0 || iface.Purpose == linodego.InterfacePurposePublic { - continue - } - - // For sweep purposes, we'll let the instance deletion handle interface cleanup - // since interfaces are tied to instances - } - } - } - - return nil -} - func TestAccLinodeInterface_vlan_basic(t *testing.T) { t.Parallel() From 225c6841b6fb9e883b84ae43d65eee516e910daf Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:02:55 -0400 Subject: [PATCH 11/29] Add ID for firewall setting resource and data source (#2136) * Add ID for firewall setting resource and data source * go mod tidy --- go.mod | 2 +- .../firewallsettings/framework_datasource.go | 13 ++++++--- .../framework_model_unit_test.go | 22 +++++++------- linode/firewallsettings/framework_models.go | 11 +++++-- linode/firewallsettings/framework_resource.go | 29 ++++++++++++++----- .../framework_resource_schema.go | 8 +++++ 6 files changed, 58 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index b15f0315c..62e0e1e21 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/aws/smithy-go v1.23.0 github.com/go-resty/resty/v2 v2.16.5 github.com/google/go-cmp v0.7.0 + github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.5.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-version v1.7.0 @@ -64,7 +65,6 @@ require ( github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-checkpoint v0.5.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/linode/firewallsettings/framework_datasource.go b/linode/firewallsettings/framework_datasource.go index a3fa3bab8..05def60de 100644 --- a/linode/firewallsettings/framework_datasource.go +++ b/linode/firewallsettings/framework_datasource.go @@ -4,6 +4,7 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/linode/terraform-provider-linode/v3/linode/helper" ) @@ -27,12 +28,16 @@ func (d *DataSource) Read( req datasource.ReadRequest, resp *datasource.ReadResponse, ) { - var state FirewallSettingsModel + var data FirewallSettingsBaseModel client := d.Meta.Client - resp.Diagnostics.Append(resp.State.Get(ctx, &state)...) + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + tflog.Trace(ctx, "client.GetFirewallSettings(...)") firewallSettings, err := client.GetFirewallSettings(ctx) if err != nil { resp.Diagnostics.AddError( @@ -42,10 +47,10 @@ func (d *DataSource) Read( return } - state.FlattenFirewallSettings(ctx, *firewallSettings, false, &resp.Diagnostics) + data.FlattenFirewallSettings(ctx, *firewallSettings, false, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } diff --git a/linode/firewallsettings/framework_model_unit_test.go b/linode/firewallsettings/framework_model_unit_test.go index cc941cda6..a9ea4e3e7 100644 --- a/linode/firewallsettings/framework_model_unit_test.go +++ b/linode/firewallsettings/framework_model_unit_test.go @@ -27,7 +27,7 @@ func TestFlattenFirewallSettings(t *testing.T) { }, } - expectedModelWhenNotPreservingKnown := firewallsettings.FirewallSettingsModel{ + expectedModelWhenNotPreservingKnown := firewallsettings.FirewallSettingsBaseModel{ DefaultFirewallIDs: types.ObjectValueMust( defaultFirewallIDsObjectAttrType, map[string]attr.Value{ @@ -40,13 +40,13 @@ func TestFlattenFirewallSettings(t *testing.T) { } tests := map[string]struct { - model firewallsettings.FirewallSettingsModel + model firewallsettings.FirewallSettingsBaseModel settings linodego.FirewallSettings - expected firewallsettings.FirewallSettingsModel + expected firewallsettings.FirewallSettingsBaseModel preserveKnown bool }{ "unknown default firewall IDs with preserving known": { - model: firewallsettings.FirewallSettingsModel{ + model: firewallsettings.FirewallSettingsBaseModel{ DefaultFirewallIDs: types.ObjectUnknown(defaultFirewallIDsObjectAttrType), }, settings: firewallSettings, @@ -54,17 +54,17 @@ func TestFlattenFirewallSettings(t *testing.T) { preserveKnown: true, }, "null default firewall IDs with preserving known": { - model: firewallsettings.FirewallSettingsModel{ + model: firewallsettings.FirewallSettingsBaseModel{ DefaultFirewallIDs: types.ObjectNull(defaultFirewallIDsObjectAttrType), }, settings: firewallSettings, - expected: firewallsettings.FirewallSettingsModel{ + expected: firewallsettings.FirewallSettingsBaseModel{ DefaultFirewallIDs: types.ObjectNull(defaultFirewallIDsObjectAttrType), }, preserveKnown: true, }, "known default firewall IDs with preserving known": { - model: firewallsettings.FirewallSettingsModel{ + model: firewallsettings.FirewallSettingsBaseModel{ DefaultFirewallIDs: types.ObjectValueMust( defaultFirewallIDsObjectAttrType, map[string]attr.Value{ @@ -76,7 +76,7 @@ func TestFlattenFirewallSettings(t *testing.T) { ), }, settings: firewallSettings, - expected: firewallsettings.FirewallSettingsModel{ + expected: firewallsettings.FirewallSettingsBaseModel{ DefaultFirewallIDs: types.ObjectValueMust( defaultFirewallIDsObjectAttrType, map[string]attr.Value{ @@ -90,7 +90,7 @@ func TestFlattenFirewallSettings(t *testing.T) { preserveKnown: true, }, "unknown default firewall IDs without preserving known": { - model: firewallsettings.FirewallSettingsModel{ + model: firewallsettings.FirewallSettingsBaseModel{ DefaultFirewallIDs: types.ObjectUnknown(defaultFirewallIDsObjectAttrType), }, settings: firewallSettings, @@ -98,7 +98,7 @@ func TestFlattenFirewallSettings(t *testing.T) { preserveKnown: false, }, "null default firewall IDs without preserving known": { - model: firewallsettings.FirewallSettingsModel{ + model: firewallsettings.FirewallSettingsBaseModel{ DefaultFirewallIDs: types.ObjectNull(defaultFirewallIDsObjectAttrType), }, settings: firewallSettings, @@ -106,7 +106,7 @@ func TestFlattenFirewallSettings(t *testing.T) { preserveKnown: false, }, "known default firewall IDs without preserving known": { - model: firewallsettings.FirewallSettingsModel{ + model: firewallsettings.FirewallSettingsBaseModel{ DefaultFirewallIDs: types.ObjectValueMust( defaultFirewallIDsObjectAttrType, map[string]attr.Value{ diff --git a/linode/firewallsettings/framework_models.go b/linode/firewallsettings/framework_models.go index 6cbdb1e4e..a48cab814 100644 --- a/linode/firewallsettings/framework_models.go +++ b/linode/firewallsettings/framework_models.go @@ -17,11 +17,16 @@ type DefaultFirewallIDsAttributeModel struct { VPCInterface types.Int64 `tfsdk:"vpc_interface"` } -type FirewallSettingsModel struct { +type FirewallSettingsBaseModel struct { DefaultFirewallIDs types.Object `tfsdk:"default_firewall_ids"` } -func (fsds *FirewallSettingsModel) GetUpdateOptions( +type FirewallSettingsResourceModel struct { + FirewallSettingsBaseModel + ID types.String `tfsdk:"id"` +} + +func (fsds *FirewallSettingsBaseModel) GetUpdateOptions( ctx context.Context, diags *diag.Diagnostics, ) (opts linodego.FirewallSettingsUpdateOptions) { @@ -78,7 +83,7 @@ func (fsds *FirewallSettingsModel) GetUpdateOptions( return opts } -func (fsds *FirewallSettingsModel) FlattenFirewallSettings( +func (fsds *FirewallSettingsBaseModel) FlattenFirewallSettings( ctx context.Context, settings linodego.FirewallSettings, preserveKnown bool, diff --git a/linode/firewallsettings/framework_resource.go b/linode/firewallsettings/framework_resource.go index 7dba433c5..19c482dfc 100644 --- a/linode/firewallsettings/framework_resource.go +++ b/linode/firewallsettings/framework_resource.go @@ -4,8 +4,10 @@ import ( "context" "fmt" + "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/helper" @@ -32,7 +34,7 @@ func (r *Resource) Create( resp *resource.CreateResponse, ) { tflog.Debug(ctx, "Create "+r.Config.Name) - var plan FirewallSettingsModel + var plan FirewallSettingsResourceModel client := r.Meta.Client resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) @@ -44,9 +46,17 @@ func (r *Resource) Create( if resp.Diagnostics.HasError() { return } - // IDs should always be overridden during creation (see #1085) - // TODO: Remove when Crossplane empty string ID issue is resolved - // plan.ID = types.StringValue(strconv.Itoa(firewall.ID)) + + // Generate UUID v7 for the resource + id, err := uuid.NewV7() + if err != nil { + resp.Diagnostics.AddError( + "Failed to generate UUID", + fmt.Sprintf("An error occurred while generating a UUID for firewall settings resource: %s", err.Error()), + ) + return + } + plan.ID = types.StringValue(id.String()) resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } @@ -60,13 +70,14 @@ func (r *Resource) Read( client := r.Meta.Client - var state FirewallSettingsModel + var state FirewallSettingsResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } + tflog.Trace(ctx, "client.GetFirewallSettings(...)") firewallSettings, err := client.GetFirewallSettings(ctx) if err != nil { resp.Diagnostics.AddError( @@ -80,7 +91,6 @@ func (r *Resource) Read( } state.FlattenFirewallSettings(ctx, *firewallSettings, false, &resp.Diagnostics) - resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } @@ -92,7 +102,7 @@ func (r *Resource) Update( tflog.Debug(ctx, "Update "+r.Config.Name) client := r.Meta.Client - var plan FirewallSettingsModel + var plan FirewallSettingsResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { @@ -110,7 +120,7 @@ func (r *Resource) Update( func updateFirewallSettings( ctx context.Context, client *linodego.Client, - plan *FirewallSettingsModel, + plan *FirewallSettingsResourceModel, diags *diag.Diagnostics, ) { tflog.Debug(ctx, "Updating firewall settings") @@ -120,6 +130,9 @@ func updateFirewallSettings( return } + tflog.Debug(ctx, "client.UpdateFirewallSettings(...)", map[string]any{ + "options": updateOptions, + }) firewallSettings, err := client.UpdateFirewallSettings(ctx, updateOptions) if err != nil { diags.AddError( diff --git a/linode/firewallsettings/framework_resource_schema.go b/linode/firewallsettings/framework_resource_schema.go index 01b9bf480..d67386891 100644 --- a/linode/firewallsettings/framework_resource_schema.go +++ b/linode/firewallsettings/framework_resource_schema.go @@ -4,10 +4,18 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" ) var FrameworkResourceSchema = schema.Schema{ Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "A unique identifier for this resource (UUID v7).", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, "default_firewall_ids": schema.SingleNestedAttribute{ Optional: true, Description: "The default firewall ID for a linode, nodebalancer, public_interface, or vpc_interface.", From 277bdbfbed571243e4c30df6eb3dfbd1889c1461 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 23 Oct 2025 16:07:13 -0400 Subject: [PATCH 12/29] Added Image Share Group Token resource --- .../framework_models.go | 86 +++++++++ .../framework_resource.go | 172 ++++++++++++++++++ .../framework_schema_resource.go | 63 +++++++ linode/framework_provider.go | 2 + .../framework_models.go | 2 +- 5 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 linode/consumerimagesharegrouptoken/framework_models.go create mode 100644 linode/consumerimagesharegrouptoken/framework_resource.go create mode 100644 linode/consumerimagesharegrouptoken/framework_schema_resource.go diff --git a/linode/consumerimagesharegrouptoken/framework_models.go b/linode/consumerimagesharegrouptoken/framework_models.go new file mode 100644 index 000000000..5b9dbb7bc --- /dev/null +++ b/linode/consumerimagesharegrouptoken/framework_models.go @@ -0,0 +1,86 @@ +package consumerimagesharegrouptoken + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type ResourceModel struct { + ValidForShareGroupUUID types.String `tfsdk:"valid_for_sharegroup_uuid"` + Label types.String `tfsdk:"label"` + Token types.String `tfsdk:"token"` + TokenUUID types.String `tfsdk:"token_uuid"` + Status types.String `tfsdk:"status"` + Created timetypes.RFC3339 `tfsdk:"created"` + Updated timetypes.RFC3339 `tfsdk:"updated"` + Expiry timetypes.RFC3339 `tfsdk:"expiry"` + ShareGroupUUID types.String `tfsdk:"sharegroup_uuid"` + ShareGroupLabel types.String `tfsdk:"sharegroup_label"` +} + +func (data *ResourceModel) FlattenImageShareGroupCreateToken( + resp *linodego.ImageShareGroupCreateTokenResponse, +) { + data.ValidForShareGroupUUID = types.StringValue(resp.ValidForShareGroupUUID) + + if resp.Label != "" { + data.Label = types.StringValue(resp.Label) + } else { + data.Label = types.StringNull() + } + + data.TokenUUID = types.StringValue(resp.TokenUUID) + data.Status = types.StringValue(resp.Status) + data.Created = timetypes.NewRFC3339TimePointerValue(resp.Created) + data.Updated = timetypes.NewRFC3339TimePointerValue(resp.Updated) + data.Expiry = timetypes.NewRFC3339TimePointerValue(resp.Expiry) + data.ShareGroupUUID = types.StringPointerValue(resp.ShareGroupUUID) + data.ShareGroupLabel = types.StringPointerValue(resp.ShareGroupLabel) + + // Token is only present in the API response during creation + data.Token = types.StringValue(resp.Token) +} + +func (data *ResourceModel) FlattenImageShareGroupToken( + token *linodego.ImageShareGroupToken, + preserveKnown bool, +) { + // Do not touch Token here since it’s only returned at create time + + data.ValidForShareGroupUUID = helper.KeepOrUpdateString(data.ValidForShareGroupUUID, token.ValidForShareGroupUUID, preserveKnown) + + if token.Label != "" { + data.Label = helper.KeepOrUpdateString(data.Label, token.Label, preserveKnown) + } else if !preserveKnown { + data.Label = types.StringNull() + } + + data.TokenUUID = helper.KeepOrUpdateString(data.TokenUUID, token.TokenUUID, preserveKnown) + data.Status = helper.KeepOrUpdateString(data.Status, token.Status, preserveKnown) + data.Created = helper.KeepOrUpdateValue( + data.Created, timetypes.NewRFC3339TimePointerValue(token.Created), preserveKnown, + ) + data.Updated = helper.KeepOrUpdateValue( + data.Updated, timetypes.NewRFC3339TimePointerValue(token.Updated), preserveKnown, + ) + data.Expiry = helper.KeepOrUpdateValue( + data.Expiry, timetypes.NewRFC3339TimePointerValue(token.Expiry), preserveKnown, + ) + data.ShareGroupUUID = helper.KeepOrUpdateStringPointer(data.ShareGroupUUID, token.ShareGroupUUID, preserveKnown) + data.ShareGroupLabel = helper.KeepOrUpdateStringPointer(data.ShareGroupLabel, token.ShareGroupLabel, preserveKnown) +} + +func (m *ResourceModel) CopyFrom(other ResourceModel, preserveKnown bool) { + m.ValidForShareGroupUUID = helper.KeepOrUpdateValue(m.ValidForShareGroupUUID, other.ValidForShareGroupUUID, preserveKnown) + m.Token = helper.KeepOrUpdateValue(m.Token, other.Token, preserveKnown) + m.Label = helper.KeepOrUpdateValue(m.Label, other.Label, preserveKnown) + m.TokenUUID = helper.KeepOrUpdateValue(m.TokenUUID, other.TokenUUID, preserveKnown) + m.Status = helper.KeepOrUpdateValue(m.Status, other.Status, preserveKnown) + m.Created = helper.KeepOrUpdateValue(m.Created, other.Created, preserveKnown) + m.Updated = helper.KeepOrUpdateValue(m.Updated, other.Updated, preserveKnown) + m.Expiry = helper.KeepOrUpdateValue(m.Expiry, other.Expiry, preserveKnown) + m.ShareGroupUUID = helper.KeepOrUpdateValue(m.ShareGroupUUID, other.ShareGroupUUID, preserveKnown) + m.ShareGroupLabel = helper.KeepOrUpdateValue(m.ShareGroupLabel, other.ShareGroupLabel, preserveKnown) +} diff --git a/linode/consumerimagesharegrouptoken/framework_resource.go b/linode/consumerimagesharegrouptoken/framework_resource.go new file mode 100644 index 000000000..a007b4a02 --- /dev/null +++ b/linode/consumerimagesharegrouptoken/framework_resource.go @@ -0,0 +1,172 @@ +package consumerimagesharegrouptoken + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "linode_consumer_image_share_group_token", + IDType: types.Int64Type, + Schema: &frameworkResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + tflog.Debug(ctx, "Create "+r.Config.Name) + + var plan ResourceModel + client := r.Meta.Client + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + createOpts := linodego.ImageShareGroupCreateTokenOptions{ + ValidForShareGroupUUID: plan.ValidForShareGroupUUID.ValueString(), + Label: plan.Label.ValueStringPointer(), + } + + tflog.Debug(ctx, "client.ImageShareGroupCreateToken(...)", map[string]any{ + "options": createOpts, + }) + + token, err := client.ImageShareGroupCreateToken(ctx, createOpts) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create Image Share Group Token.", + err.Error(), + ) + return + } + + plan.FlattenImageShareGroupCreateToken(token) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + tflog.Debug(ctx, "Read "+r.Config.Name) + + client := r.Meta.Client + + var state ResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + tokenUUID := state.TokenUUID.ValueString() + + token, err := client.ImageShareGroupGetToken(ctx, tokenUUID) + if err != nil { + resp.Diagnostics.AddError( + "Failed to read Image Share Group Token.", + err.Error(), + ) + return + } + + state.FlattenImageShareGroupToken(token, true) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + tflog.Debug(ctx, "Update "+r.Config.Name) + + client := r.Meta.Client + + var plan ResourceModel + var state ResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + tokenUUID := state.TokenUUID.ValueString() + + var updateOpts linodego.ImageShareGroupUpdateTokenOptions + shouldUpdate := false + + if !state.Label.Equal(plan.Label) { + shouldUpdate = true + updateOpts.Label = plan.Label.ValueString() + } + + if shouldUpdate { + tflog.Debug(ctx, "client.ImageShareGroupUpdateToken(...)", map[string]any{ + "options": updateOpts, + }) + + token, err := client.ImageShareGroupUpdateToken(ctx, tokenUUID, updateOpts) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to update Image Share Group Token (%d).", tokenUUID), + err.Error(), + ) + return + } + + plan.FlattenImageShareGroupToken(token, false) + if resp.Diagnostics.HasError() { + return + } + } + plan.CopyFrom(state, true) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + tflog.Debug(ctx, "Delete "+r.Config.Name) + + var state ResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + tokenUUID := state.TokenUUID.ValueString() + + client := r.Meta.Client + + err := client.ImageShareGroupRemoveToken(ctx, tokenUUID) + if err != nil { + resp.Diagnostics.AddError("Failed to Delete Image Share Group Token.", err.Error()) + return + } +} diff --git a/linode/consumerimagesharegrouptoken/framework_schema_resource.go b/linode/consumerimagesharegrouptoken/framework_schema_resource.go new file mode 100644 index 000000000..66455e42a --- /dev/null +++ b/linode/consumerimagesharegrouptoken/framework_schema_resource.go @@ -0,0 +1,63 @@ +package consumerimagesharegrouptoken + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "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" +) + +var frameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "valid_for_sharegroup_uuid": schema.StringAttribute{ + Description: "The UUID of the Image Share Group this token is for.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "label": schema.StringAttribute{ + Description: "The label of the token.", + Optional: true, + }, + "token": schema.StringAttribute{ + Description: "The one-time-use token to be provided to the Share Group Producer.", + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "token_uuid": schema.StringAttribute{ + Description: "The UUID of the token.", + Computed: true, + }, + "status": schema.StringAttribute{ + Description: "The status of the token.", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "When this token was created.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "updated": schema.StringAttribute{ + Description: "When this token was last updated.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "expiry": schema.StringAttribute{ + Description: "When this token will expire.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "sharegroup_uuid": schema.StringAttribute{ + Description: "The UUID of the Image Share Group this token is for.", + Computed: true, + }, + "sharegroup_label": schema.StringAttribute{ + Description: "The label of the Image Share Group this token is for.", + Computed: true, + }, + }, +} diff --git a/linode/framework_provider.go b/linode/framework_provider.go index fcd7d1083..b2402be9c 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -2,6 +2,7 @@ package linode import ( "context" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -258,6 +259,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res databasemysqlv2.NewResource, producerimagesharegroup.NewResource, producerimagesharegroupmember.NewResource, + consumerimagesharegrouptoken.NewResource, } } diff --git a/linode/producerimagesharegroupmember/framework_models.go b/linode/producerimagesharegroupmember/framework_models.go index ab7e41a20..8ae0f83bc 100644 --- a/linode/producerimagesharegroupmember/framework_models.go +++ b/linode/producerimagesharegroupmember/framework_models.go @@ -22,7 +22,7 @@ func (data *ResourceModel) FlattenImageShareGroupMember( member *linodego.ImageShareGroupMember, preserveKnown bool, ) { - // We do not touch ShareGroupID Token as they are not returned by the API and must be preserved as-is. + // Do not touch ShareGroupID or Token as they are not returned by the API and must be preserved data.Label = helper.KeepOrUpdateString(data.Label, member.Label, preserveKnown) data.TokenUUID = helper.KeepOrUpdateString(data.TokenUUID, member.TokenUUID, preserveKnown) From da06e9416fd52e23509013c9947633fb11388610 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:20:13 -0400 Subject: [PATCH 13/29] Add Linode interface related packages to the integration test CI (#2138) --- .github/workflows/integration_tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 52d48f39e..aa3602efd 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -59,7 +59,7 @@ jobs: echo "LINODE_TOKEN=${{ secrets.LINODE_TOKEN_USER_1 }}" >> $GITHUB_ENV ;; "USER_2") - echo "TEST_SUITE=databasemysqlv2,firewall,firewalldevice,firewalls,image,images,instancenetworking,instancesharedips,instancetype,instancetypes,ipv6range,ipv6ranges,kernel,kernels,nb,nbconfig,nbconfigs,nbnode,nbs,nbvpc,nbvpcs,sshkey,sshkeys,vlan,volume,volumes,vpc,vpcs,vpcsubnets,vpcips" >> $GITHUB_ENV + echo "TEST_SUITE=databasemysqlv2,firewall,firewallsettings,firewalltemplate,firewalltemplates,firewalldevice,firewalls,image,images,instancenetworking,instancesharedips,instancetype,instancetypes,ipv6range,ipv6ranges,kernel,kernels,nb,nbconfig,nbconfigs,nbnode,nbs,nbvpc,nbvpcs,sshkey,sshkeys,vlan,volume,volumes,vpc,vpcs,vpcsubnets,vpcips" >> $GITHUB_ENV echo "LINODE_TOKEN=${{ secrets.LINODE_TOKEN_USER_2 }}" >> $GITHUB_ENV ;; "USER_3") @@ -67,7 +67,7 @@ jobs: echo "LINODE_TOKEN=${{ secrets.LINODE_TOKEN_USER_3 }}" >> $GITHUB_ENV ;; "USER_4") - echo "TEST_SUITE=lke,lkeclusters,lkenodepool,lkeversions,obj,objbucket,placementgroup,placementgroups,placementgorupassignment,token,user,users" >> $GITHUB_ENV + echo "TEST_SUITE=linodeinterface,lke,lkeclusters,lkenodepool,lkeversions,obj,objbucket,placementgroup,placementgroups,placementgorupassignment,token,user,users" >> $GITHUB_ENV echo "LINODE_TOKEN=${{ secrets.LINODE_TOKEN_USER_4 }}" >> $GITHUB_ENV ;; esac From a95fe04a5131e70c7d7e2be21c03b81a9491cc82 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Mon, 27 Oct 2025 13:48:30 -0400 Subject: [PATCH 14/29] Added docs and integration tests for ImageShareGroupToken and ImageShareGroupMember resources --- .../producer_image_share_group.md | 2 +- ...producer_image_share_group_image_shares.md | 2 +- .../producer_image_share_groups.md | 2 +- .../consumer_image_share_group_token.md | 49 +++++++++++ docs/resources/producer_image_share_group.md | 6 +- .../producer_image_share_group_member.md | 46 ++++++++++ linode/acceptance/provider_factories.go | 27 ++++++ linode/acceptance/util.go | 25 ++++++ linode/acceptance/with_client.go | 36 ++++++++ .../framework_resource.go | 3 +- .../resource_test.go | 88 +++++++++++++++++++ .../tmpl/basic.gotf | 16 ++++ .../tmpl/template.go | 20 +++++ linode/framework_provider.go | 2 +- .../resource_test.go | 85 ++++++++++++++++++ .../tmpl/basic.gotf | 24 +++++ .../tmpl/template.go | 22 +++++ 17 files changed, 445 insertions(+), 10 deletions(-) create mode 100644 docs/resources/consumer_image_share_group_token.md create mode 100644 docs/resources/producer_image_share_group_member.md create mode 100644 linode/acceptance/with_client.go create mode 100644 linode/consumerimagesharegrouptoken/resource_test.go create mode 100644 linode/consumerimagesharegrouptoken/tmpl/basic.gotf create mode 100644 linode/consumerimagesharegrouptoken/tmpl/template.go create mode 100644 linode/producerimagesharegroupmember/resource_test.go create mode 100644 linode/producerimagesharegroupmember/tmpl/basic.gotf create mode 100644 linode/producerimagesharegroupmember/tmpl/template.go diff --git a/docs/data-sources/producer_image_share_group.md b/docs/data-sources/producer_image_share_group.md index a5a7fb302..64df2f531 100644 --- a/docs/data-sources/producer_image_share_group.md +++ b/docs/data-sources/producer_image_share_group.md @@ -4,7 +4,7 @@ description: |- Provides details about an Image Share Group created by a producer. --- -# Data Source: linode\producer\_image\_share\_group +# Data Source: linode\_producer\_image\_share\_group `linode_producer_image_share_group` provides details about an Image Share Group. For more information, see the [Linode APIv4 docs](TODO). diff --git a/docs/data-sources/producer_image_share_group_image_shares.md b/docs/data-sources/producer_image_share_group_image_shares.md index bd5a55887..5f56f0dde 100644 --- a/docs/data-sources/producer_image_share_group_image_shares.md +++ b/docs/data-sources/producer_image_share_group_image_shares.md @@ -4,7 +4,7 @@ description: |- Lists Images shared in the specified Image Share Group on your account. --- -# Data Source: linode\producer\_image\_share\_group\_image\_shares +# Data Source: linode\_producer\_image\_share\_group\_image\_shares Provides information about a list of Images shared in the specified Image Share Group that match a set of filters. For more information, see the [Linode APIv4 docs](TODO). diff --git a/docs/data-sources/producer_image_share_groups.md b/docs/data-sources/producer_image_share_groups.md index d555df668..1fa12bfd5 100644 --- a/docs/data-sources/producer_image_share_groups.md +++ b/docs/data-sources/producer_image_share_groups.md @@ -4,7 +4,7 @@ description: |- Lists Image Share Groups on your account. --- -# Data Source: linode\producer\_image\_share\_groups +# Data Source: linode\_producer\_image\_share\_groups Provides information about a list of Image Share Groups that match a set of filters. For more information, see the [Linode APIv4 docs](TODO). diff --git a/docs/resources/consumer_image_share_group_token.md b/docs/resources/consumer_image_share_group_token.md new file mode 100644 index 000000000..2839bac29 --- /dev/null +++ b/docs/resources/consumer_image_share_group_token.md @@ -0,0 +1,49 @@ +--- +page_title: "Linode: linode_consumer_image_share_group_token" +description: |- + Manages a token for an Image Share Group. +--- + +# linode\_consumer\_image\_share\_group\_token + +Manages a token for an Image Share Group. +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +Create a token for an Image Share Group: + +```terraform +resource "linode_consumer_image_share_group_token" "example" { + valid_for_sharegroup_uuid = "03fbb93e-c27d-4c4a-9180-67f6e0cd74ca" + label = "example-token" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `valid_for_sharegroup_uuid` - (Required) The UUID of the Image Share Group for which to create a token. + +* `label` - (Optional) A label for the token. + +## Attributes Reference + +In addition to all the arguments above, the following attributes are exported. + +* `token` - The one-time-use token to be provided to the Image Share Group Producer. + +* `token_uuid` - The UUID of the token. + +* `status` - The status of the token. + +* `created` - When the token was created. + +* `updated` - When the token was last updated. + +* `expiry` - When the token will expire. + +* `sharegroup_uuid` - The UUID of the Image Share Group that the token is for. + +* `sharegroup_label` - The label of the Image Share Group that the token is for. diff --git a/docs/resources/producer_image_share_group.md b/docs/resources/producer_image_share_group.md index 3f66965bd..286253298 100644 --- a/docs/resources/producer_image_share_group.md +++ b/docs/resources/producer_image_share_group.md @@ -4,7 +4,7 @@ description: |- Manages an Image Share Group. --- -# linode\producer\_image\_share\_group +# linode\_producer\_image\_share\_group Manages an Image Share Group. For more information, see the [Linode APIv4 docs](TODO). @@ -54,10 +54,6 @@ In addition to all the arguments above, the following attributes are exported. * `uuid` - The UUID of the Image Share Group. -* `label` - The label of the Image Share Group. - -* `description` - The description of the Image Share Group. - * `is_suspended` - Whether the Image Share Group is suspended. * `images_count` - The number of images in the Image Share Group. diff --git a/docs/resources/producer_image_share_group_member.md b/docs/resources/producer_image_share_group_member.md new file mode 100644 index 000000000..752fd8c4b --- /dev/null +++ b/docs/resources/producer_image_share_group_member.md @@ -0,0 +1,46 @@ +--- +page_title: "Linode: linode_producer_image_share_group_member" +description: |- + Manages a member of an Image Share Group. +--- + +# linode\_producer\_image\_share\_group\_member + +Manages a member of an Image Share Group. +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +Accept a member into an Image Share Group: + +```terraform +resource "linode_producer_image_share_group_member" "example" { + sharegroup_id = 12345 + token = abcdefghijklmnopqrstuvwxyz0123456789 + label = "example-member" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `sharegroup_id` - (Required) The ID of the Image Share Group to which the member will be added. + +* `token` - (Required) The token of the prospective member. + +* `label` - (Required) A label for the member. + +## Attributes Reference + +In addition to all the arguments above, the following attributes are exported. + +* `token_uuid` - The UUID of member's token. + +* `status` - The status of the member. + +* `created` - When the member was created. + +* `updated` - When the member was last updated. + +* `expiry` - When the member will expire. diff --git a/linode/acceptance/provider_factories.go b/linode/acceptance/provider_factories.go index 10b83b8a8..e9365051b 100644 --- a/linode/acceptance/provider_factories.go +++ b/linode/acceptance/provider_factories.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-mux/tf5to6server" @@ -46,3 +47,29 @@ var HttpExternalProviders = map[string]resource.ExternalProvider{ Source: "hashicorp/http", }, } + +var ProtoV6CustomProviderFactories = map[string]func(provider provider.Provider) (tfprotov6.ProviderServer, error){ + "linode": func(provider provider.Provider) (tfprotov6.ProviderServer, error) { + ctx := context.Background() + + upgradedSDKProvider, err := tf5to6server.UpgradeServer( + ctx, + TestAccSDKv2Providers["linode"].GRPCProvider, + ) + if err != nil { + return nil, fmt.Errorf("failed to upgrade SDKv2 GRPC provider: %w", err) + } + + providers := []func() tfprotov6.ProviderServer{ + providerserver.NewProtocol6(provider), + func() tfprotov6.ProviderServer { return upgradedSDKProvider }, + } + + muxServer, err := tf6muxserver.NewMuxServer(ctx, providers...) + if err != nil { + return nil, err + } + + return muxServer.ProviderServer(), nil + }, +} diff --git a/linode/acceptance/util.go b/linode/acceptance/util.go index 0c9ad55eb..17e98fa50 100644 --- a/linode/acceptance/util.go +++ b/linode/acceptance/util.go @@ -762,3 +762,28 @@ func GetTestClient() (*linodego.Client, error) { return client, nil } + +func GetTestClientAlternateToken(tokenName string) (*linodego.Client, error) { + token := os.Getenv(tokenName) + if token == "" { + return nil, fmt.Errorf("%s must be set for acceptance tests", tokenName) + } + + apiVersion := os.Getenv("LINODE_API_VERSION") + if apiVersion == "" { + apiVersion = "v4beta" + } + + config := &helper.Config{ + AccessToken: token, + APIVersion: apiVersion, + APIURL: os.Getenv("LINODE_URL"), + } + + client, err := config.Client(context.Background()) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/linode/acceptance/with_client.go b/linode/acceptance/with_client.go new file mode 100644 index 000000000..727659031 --- /dev/null +++ b/linode/acceptance/with_client.go @@ -0,0 +1,36 @@ +package acceptance + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +type FrameworkProviderWithClient struct { + linode.FrameworkProvider + client *linodego.Client +} + +func NewFrameworkProviderWithClient( + client *linodego.Client, +) provider.Provider { + return &FrameworkProviderWithClient{ + FrameworkProvider: *TestAccFrameworkProvider, + client: client, + } +} + +func (fp *FrameworkProviderWithClient) Configure( + ctx context.Context, + req provider.ConfigureRequest, + resp *provider.ConfigureResponse, +) { + // Call parent configure func + fp.FrameworkProvider.Configure(ctx, req, resp) + + resp.ResourceData.(*helper.FrameworkProviderMeta).Client = fp.client + resp.DataSourceData.(*helper.FrameworkProviderMeta).Client = fp.client +} diff --git a/linode/consumerimagesharegrouptoken/framework_resource.go b/linode/consumerimagesharegrouptoken/framework_resource.go index a007b4a02..5fdb1afbb 100644 --- a/linode/consumerimagesharegrouptoken/framework_resource.go +++ b/linode/consumerimagesharegrouptoken/framework_resource.go @@ -3,6 +3,7 @@ package consumerimagesharegrouptoken import ( "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" @@ -130,7 +131,7 @@ func (r *Resource) Update( token, err := client.ImageShareGroupUpdateToken(ctx, tokenUUID, updateOpts) if err != nil { resp.Diagnostics.AddError( - fmt.Sprintf("Failed to update Image Share Group Token (%d).", tokenUUID), + fmt.Sprintf("Failed to update Image Share Group Token (%s).", tokenUUID), err.Error(), ) return diff --git a/linode/consumerimagesharegrouptoken/resource_test.go b/linode/consumerimagesharegrouptoken/resource_test.go new file mode 100644 index 000000000..3f613d479 --- /dev/null +++ b/linode/consumerimagesharegrouptoken/resource_test.go @@ -0,0 +1,88 @@ +//go:build integration || consumerimagesharegrouptoken + +package consumerimagesharegrouptoken_test + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken/tmpl" +) + +// This test requires two separate Linode API tokens, one for the producer +// and one for the consumer. +// +// These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN +// environment variables. +// +// If either is not set,the test will fail. +func TestAccResourceImageShareGroupToken_basic(t *testing.T) { + t.Parallel() + + producerToken := os.Getenv("LINODE_PRODUCER_TOKEN") + consumerToken := os.Getenv("LINODE_CONSUMER_TOKEN") + + if producerToken == "" || consumerToken == "" { + t.Skip("Skipping test: both LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN must be set") + } + + producerClient, err := acceptance.GetTestClientAlternateToken("LINODE_PRODUCER_TOKEN") + if err != nil { + t.Fatalf("Failed to create producer client: %s", err) + } + + consumerClient, err := acceptance.GetTestClientAlternateToken("LINODE_CONSUMER_TOKEN") + if err != nil { + t.Fatalf("Failed to create consumer client: %s", err) + } + + producerProvider := acceptance.NewFrameworkProviderWithClient(producerClient) + consumerProvider := acceptance.NewFrameworkProviderWithClient(consumerClient) + + resourceName := "linode_consumer_image_share_group_token.foobar" + shareGroupLabel := acctest.RandomWithPrefix("tf-test") + tokenLabel := acctest.RandomWithPrefix("tf-test") + tokenLabelUpdated := tokenLabel + "-updated" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "linode-producer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](producerProvider) + }, + "linode-consumer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](consumerProvider) + }, + }, + Steps: []resource.TestStep{ + { + Config: tmpl.Basic(t, shareGroupLabel, tokenLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "token"), + resource.TestCheckResourceAttrSet(resourceName, "token_uuid"), + resource.TestCheckResourceAttrSet(resourceName, "status"), + resource.TestCheckResourceAttr(resourceName, "label", tokenLabel), + resource.TestCheckResourceAttrSet(resourceName, "valid_for_sharegroup_uuid"), + resource.TestCheckNoResourceAttr(resourceName, "sharegroup_uuid"), + resource.TestCheckNoResourceAttr(resourceName, "sharegroup_label"), + ), + }, + { + Config: tmpl.Basic(t, shareGroupLabel, tokenLabelUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "token"), + resource.TestCheckResourceAttrSet(resourceName, "token_uuid"), + resource.TestCheckResourceAttrSet(resourceName, "status"), + resource.TestCheckResourceAttr(resourceName, "label", tokenLabelUpdated), + resource.TestCheckResourceAttrSet(resourceName, "valid_for_sharegroup_uuid"), + resource.TestCheckNoResourceAttr(resourceName, "sharegroup_uuid"), + resource.TestCheckNoResourceAttr(resourceName, "sharegroup_label"), + ), + }, + }, + }) +} diff --git a/linode/consumerimagesharegrouptoken/tmpl/basic.gotf b/linode/consumerimagesharegrouptoken/tmpl/basic.gotf new file mode 100644 index 000000000..fb1b7a89c --- /dev/null +++ b/linode/consumerimagesharegrouptoken/tmpl/basic.gotf @@ -0,0 +1,16 @@ +{{ define "consumer_image_share_group_token_basic" }} + +resource "linode_producer_image_share_group" "foobar" { + provider = linode-producer + label = "{{ .ShareGroupLabel }}" + description = "Example description" +} + +resource "linode_consumer_image_share_group_token" "foobar" { + provider = linode-consumer + depends_on = [linode_producer_image_share_group.foobar] + valid_for_sharegroup_uuid = linode_producer_image_share_group.foobar.uuid + label = "{{ .TokenLabel }}" +} + +{{ end }} \ No newline at end of file diff --git a/linode/consumerimagesharegrouptoken/tmpl/template.go b/linode/consumerimagesharegrouptoken/tmpl/template.go new file mode 100644 index 000000000..723f0e9fc --- /dev/null +++ b/linode/consumerimagesharegrouptoken/tmpl/template.go @@ -0,0 +1,20 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + ShareGroupLabel string + TokenLabel string +} + +func Basic(t testing.TB, shareGroupLabel, tokenLabel string) string { + return acceptance.ExecuteTemplate(t, + "consumer_image_share_group_token_basic", TemplateData{ + ShareGroupLabel: shareGroupLabel, + TokenLabel: tokenLabel, + }) +} diff --git a/linode/framework_provider.go b/linode/framework_provider.go index b2402be9c..3ee8e3d26 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -2,7 +2,6 @@ package linode import ( "context" - "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -17,6 +16,7 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/backup" "github.com/linode/terraform-provider-linode/v3/linode/childaccount" "github.com/linode/terraform-provider-linode/v3/linode/childaccounts" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken" "github.com/linode/terraform-provider-linode/v3/linode/databasebackups" "github.com/linode/terraform-provider-linode/v3/linode/databaseengines" "github.com/linode/terraform-provider-linode/v3/linode/databasemysql" diff --git a/linode/producerimagesharegroupmember/resource_test.go b/linode/producerimagesharegroupmember/resource_test.go new file mode 100644 index 000000000..5829f144d --- /dev/null +++ b/linode/producerimagesharegroupmember/resource_test.go @@ -0,0 +1,85 @@ +//go:build integration || producerimagesharegroupmember + +package producerimagesharegroupmember_test + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupmember/tmpl" +) + +// This test requires two separate Linode API tokens, one for the producer +// and one for the consumer. +// +// These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN +// environment variables. +// +// If either is not set,the test will fail. +func TestAccResourceImageShareGroupMember_basic(t *testing.T) { + t.Parallel() + + producerToken := os.Getenv("LINODE_PRODUCER_TOKEN") + consumerToken := os.Getenv("LINODE_CONSUMER_TOKEN") + + if producerToken == "" || consumerToken == "" { + t.Skip("Skipping test: both LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN must be set") + } + + producerClient, err := acceptance.GetTestClientAlternateToken("LINODE_PRODUCER_TOKEN") + if err != nil { + t.Fatalf("Failed to create producer client: %s", err) + } + + consumerClient, err := acceptance.GetTestClientAlternateToken("LINODE_CONSUMER_TOKEN") + if err != nil { + t.Fatalf("Failed to create consumer client: %s", err) + } + + producerProvider := acceptance.NewFrameworkProviderWithClient(producerClient) + consumerProvider := acceptance.NewFrameworkProviderWithClient(consumerClient) + + resourceName := "linode_producer_image_share_group_member.foobar" + shareGroupLabel := acctest.RandomWithPrefix("tf-test") + tokenLabel := acctest.RandomWithPrefix("tf-test") + memberLabel := acctest.RandomWithPrefix("tf-test") + memberLabelUpdated := memberLabel + "-updated" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "linode-producer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](producerProvider) + }, + "linode-consumer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](consumerProvider) + }, + }, + Steps: []resource.TestStep{ + { + Config: tmpl.Basic(t, shareGroupLabel, tokenLabel, memberLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "sharegroup_id"), + resource.TestCheckResourceAttrSet(resourceName, "token"), + resource.TestCheckResourceAttrSet(resourceName, "token_uuid"), + resource.TestCheckResourceAttrSet(resourceName, "status"), + resource.TestCheckResourceAttr(resourceName, "label", memberLabel), + ), + }, + { + Config: tmpl.Basic(t, shareGroupLabel, tokenLabel, memberLabelUpdated), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "sharegroup_id"), + resource.TestCheckResourceAttrSet(resourceName, "token"), + resource.TestCheckResourceAttrSet(resourceName, "token_uuid"), + resource.TestCheckResourceAttrSet(resourceName, "status"), + resource.TestCheckResourceAttr(resourceName, "label", memberLabelUpdated), + ), + }, + }, + }) +} diff --git a/linode/producerimagesharegroupmember/tmpl/basic.gotf b/linode/producerimagesharegroupmember/tmpl/basic.gotf new file mode 100644 index 000000000..0ce8bcf3f --- /dev/null +++ b/linode/producerimagesharegroupmember/tmpl/basic.gotf @@ -0,0 +1,24 @@ +{{ define "producer_image_share_group_member_basic" }} + +resource "linode_producer_image_share_group" "foobar" { + provider = linode-producer + label = "{{ .ShareGroupLabel }}" + description = "Example description" +} + +resource "linode_consumer_image_share_group_token" "foobar" { + provider = linode-consumer + depends_on = [linode_producer_image_share_group.foobar] + valid_for_sharegroup_uuid = linode_producer_image_share_group.foobar.uuid + label = "{{ .TokenLabel }}" +} + +resource "linode_producer_image_share_group_member" "foobar" { + provider = linode-producer + depends_on = [linode_producer_image_share_group.foobar, linode_consumer_image_share_group_token.foobar] + sharegroup_id = linode_producer_image_share_group.foobar.id + token = linode_consumer_image_share_group_token.foobar.token + label = "{{ .MemberLabel }}" +} + +{{ end }} \ No newline at end of file diff --git a/linode/producerimagesharegroupmember/tmpl/template.go b/linode/producerimagesharegroupmember/tmpl/template.go new file mode 100644 index 000000000..4ef59495a --- /dev/null +++ b/linode/producerimagesharegroupmember/tmpl/template.go @@ -0,0 +1,22 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + ShareGroupLabel string + TokenLabel string + MemberLabel string +} + +func Basic(t testing.TB, shareGroupLabel, tokenLabel, memberLabel string) string { + return acceptance.ExecuteTemplate(t, + "producer_image_share_group_member_basic", TemplateData{ + ShareGroupLabel: shareGroupLabel, + TokenLabel: tokenLabel, + MemberLabel: memberLabel, + }) +} From 0b5392d65960cb1c0d28d1b94d4929fee3e42be6 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Mon, 27 Oct 2025 15:53:52 -0400 Subject: [PATCH 15/29] Added Image Share Group Member datasource --- .../producer_image_share_group_member.md | 42 +++++++++++ .../producer_image_share_group_member.md | 2 +- .../resource_test.go | 2 +- linode/framework_provider.go | 1 + .../datasource_test.go | 73 +++++++++++++++++++ .../framework_datasource.go | 64 ++++++++++++++++ .../framework_models.go | 25 +++++++ .../framework_models_unit_test.go | 26 +++++++ .../framework_schema_datasource.go | 44 +++++++++++ .../resource_test.go | 2 +- .../tmpl/data_basic.gotf | 11 +++ .../tmpl/template.go | 9 +++ 12 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 docs/data-sources/producer_image_share_group_member.md create mode 100644 linode/producerimagesharegroupmember/datasource_test.go create mode 100644 linode/producerimagesharegroupmember/framework_datasource.go create mode 100644 linode/producerimagesharegroupmember/framework_models_unit_test.go create mode 100644 linode/producerimagesharegroupmember/framework_schema_datasource.go create mode 100644 linode/producerimagesharegroupmember/tmpl/data_basic.gotf diff --git a/docs/data-sources/producer_image_share_group_member.md b/docs/data-sources/producer_image_share_group_member.md new file mode 100644 index 000000000..8dec8ea9c --- /dev/null +++ b/docs/data-sources/producer_image_share_group_member.md @@ -0,0 +1,42 @@ +--- +page_title: "Linode: linode_producer_image_share_group_member" +description: |- + Provides details about a Member of an Image Share Group. +--- + +# Data Source: linode\_producer\_image\_share\_group\_member + +`linode_producer_image_share_group_member` provides details about a Member of an Image Share Group. +For more information, see the [Linode APIv4 docs](TODO). + + +## Example Usage + +The following example shows how the datasource might be used to obtain additional information about a member of an Image Share Group. + +```hcl +data "linode_producer_image_share_group_member" "member" { + sharegroup_id = 12345 + token_uuid = "db58ab2e-3021-4b08-9426-8e456f6dd268 +} +``` + +## Argument Reference + +* `sharegroup_id` - (Required) The ID of the Image Share Group the member belongs to. + +* `token_uuid` - (Required) The UUID of member's token. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `label` - The label of the member. + +* `status` - The status of the member. + +* `created` - When the member was created. + +* `updated` - When the member was last updated. + +* `expiry` - When the member will expire. diff --git a/docs/resources/producer_image_share_group_member.md b/docs/resources/producer_image_share_group_member.md index 752fd8c4b..ced4174e0 100644 --- a/docs/resources/producer_image_share_group_member.md +++ b/docs/resources/producer_image_share_group_member.md @@ -16,7 +16,7 @@ Accept a member into an Image Share Group: ```terraform resource "linode_producer_image_share_group_member" "example" { sharegroup_id = 12345 - token = abcdefghijklmnopqrstuvwxyz0123456789 + token = "abcdefghijklmnopqrstuvwxyz0123456789" label = "example-member" } ``` diff --git a/linode/consumerimagesharegrouptoken/resource_test.go b/linode/consumerimagesharegrouptoken/resource_test.go index 3f613d479..41fafdf53 100644 --- a/linode/consumerimagesharegrouptoken/resource_test.go +++ b/linode/consumerimagesharegrouptoken/resource_test.go @@ -19,7 +19,7 @@ import ( // These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN // environment variables. // -// If either is not set,the test will fail. +// If either is not set,the test will be skipped. func TestAccResourceImageShareGroupToken_basic(t *testing.T) { t.Parallel() diff --git a/linode/framework_provider.go b/linode/framework_provider.go index 3ee8e3d26..341faeda0 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -343,5 +343,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource producerimagesharegroup.NewDataSource, producerimagesharegroups.NewDataSource, producerimagesharegroupimageshares.NewDataSource, + producerimagesharegroupmember.NewDataSource, } } diff --git a/linode/producerimagesharegroupmember/datasource_test.go b/linode/producerimagesharegroupmember/datasource_test.go new file mode 100644 index 000000000..0e3ab1f35 --- /dev/null +++ b/linode/producerimagesharegroupmember/datasource_test.go @@ -0,0 +1,73 @@ +//go:build integration || producerimagesharegroupmember + +package producerimagesharegroupmember_test + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupmember/tmpl" +) + +// This test requires two separate Linode API tokens, one for the producer +// and one for the consumer. +// +// These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN +// environment variables. +// +// If either is not set,the test will be skipped. +func TestAccDataSourceImageShareGroupMember_basic(t *testing.T) { + t.Parallel() + + producerToken := os.Getenv("LINODE_PRODUCER_TOKEN") + consumerToken := os.Getenv("LINODE_CONSUMER_TOKEN") + + if producerToken == "" || consumerToken == "" { + t.Skip("Skipping test: both LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN must be set") + } + + producerClient, err := acceptance.GetTestClientAlternateToken("LINODE_PRODUCER_TOKEN") + if err != nil { + t.Fatalf("Failed to create producer client: %s", err) + } + + consumerClient, err := acceptance.GetTestClientAlternateToken("LINODE_CONSUMER_TOKEN") + if err != nil { + t.Fatalf("Failed to create consumer client: %s", err) + } + + producerProvider := acceptance.NewFrameworkProviderWithClient(producerClient) + consumerProvider := acceptance.NewFrameworkProviderWithClient(consumerClient) + + resourceName := "data.linode_producer_image_share_group_member.foobar" + shareGroupLabel := acctest.RandomWithPrefix("tf-test") + tokenLabel := acctest.RandomWithPrefix("tf-test") + memberLabel := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "linode-producer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](producerProvider) + }, + "linode-consumer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](consumerProvider) + }, + }, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, shareGroupLabel, tokenLabel, memberLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "sharegroup_id"), + resource.TestCheckResourceAttrSet(resourceName, "token_uuid"), + resource.TestCheckResourceAttrSet(resourceName, "status"), + resource.TestCheckResourceAttr(resourceName, "label", memberLabel), + ), + }, + }, + }) +} diff --git a/linode/producerimagesharegroupmember/framework_datasource.go b/linode/producerimagesharegroupmember/framework_datasource.go new file mode 100644 index 000000000..448010fcc --- /dev/null +++ b/linode/producerimagesharegroupmember/framework_datasource.go @@ -0,0 +1,64 @@ +package producerimagesharegroupmember + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_producer_image_share_group_member", + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (d *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+d.Config.Name) + + client := d.Meta.Client + + var data DataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + shareGroupID := helper.FrameworkSafeInt64ToInt( + data.ShareGroupID.ValueInt64(), + &resp.Diagnostics, + ) + if resp.Diagnostics.HasError() { + return + } + + tokenUUID := data.TokenUUID.ValueString() + + tflog.Trace(ctx, "client.ImageShareGroupGetMember(...)") + m, err := client.ImageShareGroupGetMember(ctx, shareGroupID, tokenUUID) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Unable to get Image Share Group Member %s", tokenUUID), err.Error(), + ) + return + } + + data.ParseImageShareGroupMember(m) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/linode/producerimagesharegroupmember/framework_models.go b/linode/producerimagesharegroupmember/framework_models.go index 8ae0f83bc..2063e5df9 100644 --- a/linode/producerimagesharegroupmember/framework_models.go +++ b/linode/producerimagesharegroupmember/framework_models.go @@ -2,6 +2,7 @@ package producerimagesharegroupmember import ( "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/helper" @@ -48,3 +49,27 @@ func (m *ResourceModel) CopyFrom(other ResourceModel, preserveKnown bool) { m.Updated = helper.KeepOrUpdateValue(m.Updated, other.Updated, preserveKnown) m.Expiry = helper.KeepOrUpdateValue(m.Expiry, other.Expiry, preserveKnown) } + +type DataSourceModel struct { + ShareGroupID types.Int64 `tfsdk:"sharegroup_id"` + TokenUUID types.String `tfsdk:"token_uuid"` + Label types.String `tfsdk:"label"` + Status types.String `tfsdk:"status"` + Created timetypes.RFC3339 `tfsdk:"created"` + Updated timetypes.RFC3339 `tfsdk:"updated"` + Expiry timetypes.RFC3339 `tfsdk:"expiry"` +} + +func (data *DataSourceModel) ParseImageShareGroupMember(m *linodego.ImageShareGroupMember, +) diag.Diagnostics { + // Do not touch ShareGroupID as it is not returned by the API and must be preserved + + data.TokenUUID = types.StringValue(m.TokenUUID) + data.Status = types.StringValue(m.Status) + data.Label = types.StringValue(m.Label) + data.Created = timetypes.NewRFC3339TimePointerValue(m.Created) + data.Updated = timetypes.NewRFC3339TimePointerValue(m.Updated) + data.Expiry = timetypes.NewRFC3339TimePointerValue(m.Expiry) + + return nil +} diff --git a/linode/producerimagesharegroupmember/framework_models_unit_test.go b/linode/producerimagesharegroupmember/framework_models_unit_test.go new file mode 100644 index 000000000..88696c4f3 --- /dev/null +++ b/linode/producerimagesharegroupmember/framework_models_unit_test.go @@ -0,0 +1,26 @@ +//go:build unit + +package producerimagesharegroupmember + +import ( + "testing" + + "github.com/linode/linodego" + "github.com/stretchr/testify/require" +) + +func TestParseImageShareGroupMember(t *testing.T) { + m := linodego.ImageShareGroupMember{ + TokenUUID: "b1966cda-4083-4414-a140-45b78d48ec27", + Status: "active", + Label: "my-label", + } + + data := &DataSourceModel{} + + data.ParseImageShareGroupMember(&m) + + require.Equal(t, "b1966cda-4083-4414-a140-45b78d48ec27", data.TokenUUID.ValueString()) + require.Equal(t, "active", data.Status.ValueString()) + require.Equal(t, "my-label", data.Label.ValueString()) +} diff --git a/linode/producerimagesharegroupmember/framework_schema_datasource.go b/linode/producerimagesharegroupmember/framework_schema_datasource.go new file mode 100644 index 000000000..85899c962 --- /dev/null +++ b/linode/producerimagesharegroupmember/framework_schema_datasource.go @@ -0,0 +1,44 @@ +package producerimagesharegroupmember + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +var Attributes = map[string]schema.Attribute{ + "sharegroup_id": schema.Int64Attribute{ + Description: "The ID of the Image Share Group the member belongs to.", + Required: true, + }, + "token_uuid": schema.StringAttribute{ + Description: "The UUID of member's token.", + Required: true, + }, + "label": schema.StringAttribute{ + Description: "The label of the member.", + Computed: true, + }, + "status": schema.StringAttribute{ + Description: "The status of the member.", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "When this member was created.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "updated": schema.StringAttribute{ + Description: "When this member was last updated.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "expiry": schema.StringAttribute{ + Description: "When this member will expire.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, +} + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: Attributes, +} diff --git a/linode/producerimagesharegroupmember/resource_test.go b/linode/producerimagesharegroupmember/resource_test.go index 5829f144d..47f790383 100644 --- a/linode/producerimagesharegroupmember/resource_test.go +++ b/linode/producerimagesharegroupmember/resource_test.go @@ -19,7 +19,7 @@ import ( // These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN // environment variables. // -// If either is not set,the test will fail. +// If either is not set,the test will be skipped. func TestAccResourceImageShareGroupMember_basic(t *testing.T) { t.Parallel() diff --git a/linode/producerimagesharegroupmember/tmpl/data_basic.gotf b/linode/producerimagesharegroupmember/tmpl/data_basic.gotf new file mode 100644 index 000000000..992e77f89 --- /dev/null +++ b/linode/producerimagesharegroupmember/tmpl/data_basic.gotf @@ -0,0 +1,11 @@ +{{ define "producer_image_share_group_member_data_basic" }} + +{{ template "producer_image_share_group_member_basic" .}} + +data "linode_producer_image_share_group_member" "foobar" { + provider = linode-producer + sharegroup_id = linode_producer_image_share_group.foobar.id + token_uuid = linode_producer_image_share_group_member.foobar.token_uuid +} + +{{ end }} \ No newline at end of file diff --git a/linode/producerimagesharegroupmember/tmpl/template.go b/linode/producerimagesharegroupmember/tmpl/template.go index 4ef59495a..1abae9a6d 100644 --- a/linode/producerimagesharegroupmember/tmpl/template.go +++ b/linode/producerimagesharegroupmember/tmpl/template.go @@ -20,3 +20,12 @@ func Basic(t testing.TB, shareGroupLabel, tokenLabel, memberLabel string) string MemberLabel: memberLabel, }) } + +func DataBasic(t testing.TB, shareGroupLabel, tokenLabel, memberLabel string) string { + return acceptance.ExecuteTemplate(t, + "producer_image_share_group_member_data_basic", TemplateData{ + ShareGroupLabel: shareGroupLabel, + TokenLabel: tokenLabel, + MemberLabel: memberLabel, + }) +} From fd32af4037642e613119a4861977e056d188df78 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Tue, 28 Oct 2025 10:33:59 -0400 Subject: [PATCH 16/29] Added ImageShareGroupMembers datasource --- .../producer_image_share_group_members.md | 76 +++++++++++++ linode/framework_provider.go | 2 + .../framework_datasource.go | 4 +- .../datasource_test.go | 89 +++++++++++++++ .../framework_datasource.go | 101 ++++++++++++++++++ .../framework_models.go | 33 ++++++ .../framework_schema_datasource.go | 37 +++++++ .../tmpl/data_basic.gotf | 57 ++++++++++ .../tmpl/template.go | 22 ++++ 9 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 docs/data-sources/producer_image_share_group_members.md create mode 100644 linode/producerimagesharegroupmembers/datasource_test.go create mode 100644 linode/producerimagesharegroupmembers/framework_datasource.go create mode 100644 linode/producerimagesharegroupmembers/framework_models.go create mode 100644 linode/producerimagesharegroupmembers/framework_schema_datasource.go create mode 100644 linode/producerimagesharegroupmembers/tmpl/data_basic.gotf create mode 100644 linode/producerimagesharegroupmembers/tmpl/template.go diff --git a/docs/data-sources/producer_image_share_group_members.md b/docs/data-sources/producer_image_share_group_members.md new file mode 100644 index 000000000..1cb4162a5 --- /dev/null +++ b/docs/data-sources/producer_image_share_group_members.md @@ -0,0 +1,76 @@ +--- +page_title: "Linode: linode_producer_image_share_group_members" +description: |- + Lists an Image Share Group's Members on your account. +--- + +# Data Source: linode\_producer\_image\_share\_group\_members + +Provides information about a list of Members of an Image Share Group that match a set of filters. +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +The following example shows how one might use this data source to list Image Share Groups. + +```hcl +data "linode_producer_image_share_group_members" "all" { + sharegroup_id = 12345 +} + +data "linode_producer_image_share_group_members" "filtered" { + sharegroup_id = 12345 + filter { + name = "label" + values = ["my-label"] + } +} + +output "all-share-group-members" { + value = data.linode_producer_image_share_group_members.all.members +} + +output "filtered-share-group-members" { + value = data.linode_producer_image_share_group_members.filtered.members +} +``` + +## Argument Reference + +The following arguments are supported: + +* [`filter`](#filter) - (Optional) A set of filters used to select Image Share Groups that meet certain requirements. + +* `sharegroup_id` - (Required) The ID of the Image Share Group for which to list members. + +### Filter + +* `name` - (Required) The name of the field to filter by. See the [Filterable Fields section](#filterable-fields) for a complete list of filterable fields. + +* `values` - (Required) A list of values for the filter to allow. These values should all be in string form. + +* `match_by` - (Optional) The method to match the field by. (`exact`, `regex`, `substring`; default `exact`) + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `token_uuid` - The UUID of member's token. + +* `label` - The label of the member. + +* `status` - The status of the member. + +* `created` - When the member was created. + +* `updated` - When the member was last updated. + +* `expiry` - When the member will expire. + +## Filterable Fields + +* `token_uuid` + +* `label` + +* `status` diff --git a/linode/framework_provider.go b/linode/framework_provider.go index 341faeda0..7c1f699f3 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -79,6 +79,7 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroup" "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupimageshares" "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupmember" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupmembers" "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroups" "github.com/linode/terraform-provider-linode/v3/linode/profile" "github.com/linode/terraform-provider-linode/v3/linode/rdns" @@ -344,5 +345,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource producerimagesharegroups.NewDataSource, producerimagesharegroupimageshares.NewDataSource, producerimagesharegroupmember.NewDataSource, + producerimagesharegroupmembers.NewDataSource, } } diff --git a/linode/producerimagesharegroupimageshares/framework_datasource.go b/linode/producerimagesharegroupimageshares/framework_datasource.go index cb4ad7e94..efc042451 100644 --- a/linode/producerimagesharegroupimageshares/framework_datasource.go +++ b/linode/producerimagesharegroupimageshares/framework_datasource.go @@ -82,7 +82,7 @@ func listWrapper( ) ([]any, error) { tflog.Trace(ctx, "client.ImageShareGroupListImageShareEntries(...)") - nbs, err := client.ImageShareGroupListImageShareEntries( + imageShares, err := client.ImageShareGroupListImageShareEntries( ctx, shareGroupID, &linodego.ListOptions{ @@ -93,6 +93,6 @@ func listWrapper( return nil, err } - return helper.TypedSliceToAny(nbs), nil + return helper.TypedSliceToAny(imageShares), nil } } diff --git a/linode/producerimagesharegroupmembers/datasource_test.go b/linode/producerimagesharegroupmembers/datasource_test.go new file mode 100644 index 000000000..79a130223 --- /dev/null +++ b/linode/producerimagesharegroupmembers/datasource_test.go @@ -0,0 +1,89 @@ +//go:build integration || producerimagesharegroupmembers + +package producerimagesharegroupmembers_test + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupmembers/tmpl" +) + +// This test requires two separate Linode API tokens, one for the producer +// and one for the consumer. +// +// These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN +// environment variables. +// +// If either is not set,the test will be skipped. +func TestAccDataSourceImageShareGroupMembers_basic(t *testing.T) { + t.Parallel() + + const dsByLabel = "data.linode_producer_image_share_group_members.by_label" + const dsByStatus = "data.linode_producer_image_share_group_members.by_status" + const dsByTokenUUID = "data.linode_producer_image_share_group_members.by_token_uuid" + + producerToken := os.Getenv("LINODE_PRODUCER_TOKEN") + consumerToken := os.Getenv("LINODE_CONSUMER_TOKEN") + + if producerToken == "" || consumerToken == "" { + t.Skip("Skipping test: both LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN must be set") + } + + producerClient, err := acceptance.GetTestClientAlternateToken("LINODE_PRODUCER_TOKEN") + if err != nil { + t.Fatalf("Failed to create producer client: %s", err) + } + + consumerClient, err := acceptance.GetTestClientAlternateToken("LINODE_CONSUMER_TOKEN") + if err != nil { + t.Fatalf("Failed to create consumer client: %s", err) + } + + producerProvider := acceptance.NewFrameworkProviderWithClient(producerClient) + consumerProvider := acceptance.NewFrameworkProviderWithClient(consumerClient) + + shareGroupLabel := acctest.RandomWithPrefix("tf-test") + tokenLabel := acctest.RandomWithPrefix("tf-test") + memberLabel := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "linode-producer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](producerProvider) + }, + "linode-consumer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](consumerProvider) + }, + }, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, shareGroupLabel, tokenLabel, memberLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dsByLabel, "members.#", "1"), + resource.TestCheckResourceAttrSet(dsByLabel, "members.0.sharegroup_id"), + resource.TestCheckResourceAttrSet(dsByLabel, "members.0.token_uuid"), + resource.TestCheckResourceAttrSet(dsByLabel, "members.0.status"), + resource.TestCheckResourceAttr(dsByLabel, "members.0.label", memberLabel), + + resource.TestCheckResourceAttr(dsByTokenUUID, "members.#", "1"), + resource.TestCheckResourceAttrSet(dsByTokenUUID, "members.0.sharegroup_id"), + resource.TestCheckResourceAttrSet(dsByTokenUUID, "members.0.token_uuid"), + resource.TestCheckResourceAttrSet(dsByTokenUUID, "members.0.status"), + resource.TestCheckResourceAttr(dsByTokenUUID, "members.0.label", memberLabel), + + resource.TestCheckResourceAttr(dsByStatus, "members.#", "1"), + resource.TestCheckResourceAttrSet(dsByStatus, "members.0.sharegroup_id"), + resource.TestCheckResourceAttrSet(dsByStatus, "members.0.token_uuid"), + resource.TestCheckResourceAttrSet(dsByStatus, "members.0.status"), + resource.TestCheckResourceAttr(dsByStatus, "members.0.label", memberLabel), + ), + }, + }, + }) +} diff --git a/linode/producerimagesharegroupmembers/framework_datasource.go b/linode/producerimagesharegroupmembers/framework_datasource.go new file mode 100644 index 000000000..737b76925 --- /dev/null +++ b/linode/producerimagesharegroupmembers/framework_datasource.go @@ -0,0 +1,101 @@ +package producerimagesharegroupmembers + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_producer_image_share_group_members", + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (r *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+r.Config.Name) + + var data ImageShareGroupMemberFilterModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + id, d := filterConfig.GenerateID(data.Filters) + if d != nil { + resp.Diagnostics.Append(d) + return + } + data.ID = id + + shareGroupID := helper.FrameworkSafeInt64ToInt( + data.ShareGroupID.ValueInt64(), + &resp.Diagnostics, + ) + if resp.Diagnostics.HasError() { + return + } + + result, d := filterConfig.GetAndFilter( + ctx, + r.Meta.Client, + data.Filters, + listWrapper(shareGroupID), + data.Order, + data.OrderBy, + ) + if d != nil { + resp.Diagnostics.Append(d) + return + } + + data.ParseImageShareGroupMembers(helper.AnySliceToTyped[linodego.ImageShareGroupMember](result)) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func listWrapper( + shareGroupID int, +) frameworkfilter.ListFunc { + return func( + ctx context.Context, + client *linodego.Client, + filter string, + ) ([]any, error) { + tflog.Trace(ctx, "client.ImageShareGroupListMembers(...)") + + nbs, err := client.ImageShareGroupListMembers( + ctx, + shareGroupID, + &linodego.ListOptions{ + Filter: filter, + }, + ) + if err != nil { + return nil, err + } + + return helper.TypedSliceToAny(nbs), nil + } +} diff --git a/linode/producerimagesharegroupmembers/framework_models.go b/linode/producerimagesharegroupmembers/framework_models.go new file mode 100644 index 000000000..d5046a1b0 --- /dev/null +++ b/linode/producerimagesharegroupmembers/framework_models.go @@ -0,0 +1,33 @@ +package producerimagesharegroupmembers + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupmember" +) + +type ImageShareGroupMemberFilterModel struct { + ID types.String `tfsdk:"id"` + ShareGroupID types.Int64 `tfsdk:"sharegroup_id"` + Filters frameworkfilter.FiltersModelType `tfsdk:"filter"` + Order types.String `tfsdk:"order"` + OrderBy types.String `tfsdk:"order_by"` + Members []producerimagesharegroupmember.DataSourceModel `tfsdk:"members"` +} + +func (model *ImageShareGroupMemberFilterModel) ParseImageShareGroupMembers( + members []linodego.ImageShareGroupMember, +) { + memberModels := make([]producerimagesharegroupmember.DataSourceModel, len(members)) + + for i, member := range members { + var memberModel producerimagesharegroupmember.DataSourceModel + memberModel.ShareGroupID = model.ShareGroupID + memberModel.ParseImageShareGroupMember(&member) + memberModels[i] = memberModel + + } + + model.Members = memberModels +} diff --git a/linode/producerimagesharegroupmembers/framework_schema_datasource.go b/linode/producerimagesharegroupmembers/framework_schema_datasource.go new file mode 100644 index 000000000..6966bb7d1 --- /dev/null +++ b/linode/producerimagesharegroupmembers/framework_schema_datasource.go @@ -0,0 +1,37 @@ +package producerimagesharegroupmembers + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" + "github.com/linode/terraform-provider-linode/v3/linode/producerimagesharegroupmember" +) + +var filterConfig = frameworkfilter.Config{ + "token_uuid": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "label": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "status": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, +} + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The data source's unique ID.", + Computed: true, + }, + "sharegroup_id": schema.Int64Attribute{ + Description: "The ID of the Image Share Group for which to list members.", + Required: true, + }, + "order": filterConfig.OrderSchema(), + "order_by": filterConfig.OrderBySchema(), + }, + Blocks: map[string]schema.Block{ + "filter": filterConfig.Schema(), + "members": schema.ListNestedBlock{ + Description: "The returned list of Image Share Group Members.", + NestedObject: schema.NestedBlockObject{ + Attributes: producerimagesharegroupmember.Attributes, + }, + }, + }, +} diff --git a/linode/producerimagesharegroupmembers/tmpl/data_basic.gotf b/linode/producerimagesharegroupmembers/tmpl/data_basic.gotf new file mode 100644 index 000000000..436326056 --- /dev/null +++ b/linode/producerimagesharegroupmembers/tmpl/data_basic.gotf @@ -0,0 +1,57 @@ +{{ define "producer_image_share_group_members_data_basic" }} + +resource "linode_producer_image_share_group" "foobar" { + provider = linode-producer + label = "{{ .ShareGroupLabel }}" + description = "Example description" +} + +resource "linode_consumer_image_share_group_token" "foobar" { + provider = linode-consumer + depends_on = [linode_producer_image_share_group.foobar] + valid_for_sharegroup_uuid = linode_producer_image_share_group.foobar.uuid + label = "{{ .TokenLabel }}" +} + +resource "linode_producer_image_share_group_member" "foobar" { + provider = linode-producer + depends_on = [linode_producer_image_share_group.foobar, linode_consumer_image_share_group_token.foobar] + sharegroup_id = linode_producer_image_share_group.foobar.id + token = linode_consumer_image_share_group_token.foobar.token + label = "{{ .MemberLabel }}" +} + +data "linode_producer_image_share_group_members" "by_label" { + depends_on = [linode_producer_image_share_group_member.foobar] + provider = linode-producer + sharegroup_id = linode_producer_image_share_group.foobar.id + + filter { + name = "label" + values = [linode_producer_image_share_group_member.foobar.label] + } +} + +data "linode_producer_image_share_group_members" "by_token_uuid" { + depends_on = [linode_producer_image_share_group_member.foobar] + provider = linode-producer + sharegroup_id = linode_producer_image_share_group.foobar.id + + filter { + name = "token_uuid" + values = [linode_producer_image_share_group_member.foobar.token_uuid] + } +} + +data "linode_producer_image_share_group_members" "by_status" { + depends_on = [linode_producer_image_share_group_member.foobar] + provider = linode-producer + sharegroup_id = linode_producer_image_share_group.foobar.id + + filter { + name = "status" + values = ["active"] + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/producerimagesharegroupmembers/tmpl/template.go b/linode/producerimagesharegroupmembers/tmpl/template.go new file mode 100644 index 000000000..20a0a12c0 --- /dev/null +++ b/linode/producerimagesharegroupmembers/tmpl/template.go @@ -0,0 +1,22 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + ShareGroupLabel string + TokenLabel string + MemberLabel string +} + +func DataBasic(t testing.TB, shareGroupLabel, tokenLabel, memberLabel string) string { + return acceptance.ExecuteTemplate(t, + "producer_image_share_group_members_data_basic", TemplateData{ + ShareGroupLabel: shareGroupLabel, + TokenLabel: tokenLabel, + MemberLabel: memberLabel, + }) +} From 9bf69ac827569e581128070d98eb325e86e54bc5 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Tue, 28 Oct 2025 15:01:02 -0400 Subject: [PATCH 17/29] Added ImageShareGroupToken datasource --- .../consumer_image_share_group_token.md | 46 ++++++++++++ .../producer_image_share_group_member.md | 3 +- .../datasource_test.go | 74 +++++++++++++++++++ .../framework_datasource.go | 56 ++++++++++++++ .../framework_models.go | 28 +++++++ .../framework_models_unit_test.go | 32 ++++++++ .../framework_schema_datasource.go | 52 +++++++++++++ .../tmpl/data_basic.gotf | 10 +++ .../tmpl/template.go | 8 ++ linode/framework_provider.go | 1 + 10 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 docs/data-sources/consumer_image_share_group_token.md create mode 100644 linode/consumerimagesharegrouptoken/datasource_test.go create mode 100644 linode/consumerimagesharegrouptoken/framework_datasource.go create mode 100644 linode/consumerimagesharegrouptoken/framework_models_unit_test.go create mode 100644 linode/consumerimagesharegrouptoken/framework_schema_datasource.go create mode 100644 linode/consumerimagesharegrouptoken/tmpl/data_basic.gotf diff --git a/docs/data-sources/consumer_image_share_group_token.md b/docs/data-sources/consumer_image_share_group_token.md new file mode 100644 index 000000000..9e7d37327 --- /dev/null +++ b/docs/data-sources/consumer_image_share_group_token.md @@ -0,0 +1,46 @@ +--- +page_title: "Linode: linode_consumer_image_share_group_token" +description: |- + Provides details about a Token for an Image Share Group. +--- + +# Data Source: linode\_consumer\_image\_share\_group\_token + +`linode_consumer_image_share_group_token` provides details about a Token for an Image Share Group. +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +The following example shows how the datasource might be used to obtain additional information about a Token for an Image Share Group. + +```hcl +data "linode_consumer_image_share_group_token" "token" { + token_uuid = "db58ab2e-3021-4b08-9426-8e456f6dd268" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `token_uuid` - The UUID of the token. + +## Attributes Reference + +In addition to all the arguments above, the following attributes are exported. + +* `label` - A label for the token. + +* `status` - The status of the token. + +* `created` - When the token was created. + +* `updated` - When the token was last updated. + +* `expiry` - When the token will expire. + +* `valid_for_sharegroup_uuid` - The UUID of the Image Share Group for which to create a token. + +* `sharegroup_uuid` - The UUID of the Image Share Group that the token is for. + +* `sharegroup_label` - The label of the Image Share Group that the token is for. diff --git a/docs/data-sources/producer_image_share_group_member.md b/docs/data-sources/producer_image_share_group_member.md index 8dec8ea9c..3cbea5932 100644 --- a/docs/data-sources/producer_image_share_group_member.md +++ b/docs/data-sources/producer_image_share_group_member.md @@ -9,7 +9,6 @@ description: |- `linode_producer_image_share_group_member` provides details about a Member of an Image Share Group. For more information, see the [Linode APIv4 docs](TODO). - ## Example Usage The following example shows how the datasource might be used to obtain additional information about a member of an Image Share Group. @@ -17,7 +16,7 @@ The following example shows how the datasource might be used to obtain additiona ```hcl data "linode_producer_image_share_group_member" "member" { sharegroup_id = 12345 - token_uuid = "db58ab2e-3021-4b08-9426-8e456f6dd268 + token_uuid = "db58ab2e-3021-4b08-9426-8e456f6dd268" } ``` diff --git a/linode/consumerimagesharegrouptoken/datasource_test.go b/linode/consumerimagesharegrouptoken/datasource_test.go new file mode 100644 index 000000000..5ba9564db --- /dev/null +++ b/linode/consumerimagesharegrouptoken/datasource_test.go @@ -0,0 +1,74 @@ +//go:build integration || consumerimagesharegrouptoken + +package consumerimagesharegrouptoken_test + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken/tmpl" +) + +// This test requires two separate Linode API tokens, one for the producer +// and one for the consumer. +// +// These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN +// environment variables. +// +// If either is not set,the test will be skipped. +func TestAccDataSourceImageShareGroupToken_basic(t *testing.T) { + t.Parallel() + + producerToken := os.Getenv("LINODE_PRODUCER_TOKEN") + consumerToken := os.Getenv("LINODE_CONSUMER_TOKEN") + + if producerToken == "" || consumerToken == "" { + t.Skip("Skipping test: both LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN must be set") + } + + producerClient, err := acceptance.GetTestClientAlternateToken("LINODE_PRODUCER_TOKEN") + if err != nil { + t.Fatalf("Failed to create producer client: %s", err) + } + + consumerClient, err := acceptance.GetTestClientAlternateToken("LINODE_CONSUMER_TOKEN") + if err != nil { + t.Fatalf("Failed to create consumer client: %s", err) + } + + producerProvider := acceptance.NewFrameworkProviderWithClient(producerClient) + consumerProvider := acceptance.NewFrameworkProviderWithClient(consumerClient) + + resourceName := "data.linode_consumer_image_share_group_token.foobar" + shareGroupLabel := acctest.RandomWithPrefix("tf-test") + tokenLabel := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "linode-producer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](producerProvider) + }, + "linode-consumer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](consumerProvider) + }, + }, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, shareGroupLabel, tokenLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "token_uuid"), + resource.TestCheckResourceAttrSet(resourceName, "status"), + resource.TestCheckResourceAttrSet(resourceName, "valid_for_sharegroup_uuid"), + resource.TestCheckNoResourceAttr(resourceName, "sharegroup_uuid"), + resource.TestCheckNoResourceAttr(resourceName, "sharegroup_label"), + resource.TestCheckResourceAttr(resourceName, "label", tokenLabel), + ), + }, + }, + }) +} diff --git a/linode/consumerimagesharegrouptoken/framework_datasource.go b/linode/consumerimagesharegrouptoken/framework_datasource.go new file mode 100644 index 000000000..6654f8a5e --- /dev/null +++ b/linode/consumerimagesharegrouptoken/framework_datasource.go @@ -0,0 +1,56 @@ +package consumerimagesharegrouptoken + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_consumer_image_share_group_token", + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (d *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+d.Config.Name) + + client := d.Meta.Client + + var data DataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tokenUUID := data.TokenUUID.ValueString() + + tflog.Trace(ctx, "client.ImageShareGroupGetToken(...)") + m, err := client.ImageShareGroupGetToken(ctx, tokenUUID) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Unable to get Image Share Group Token %s", tokenUUID), err.Error(), + ) + return + } + + data.ParseImageShareGroupToken(m) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/linode/consumerimagesharegrouptoken/framework_models.go b/linode/consumerimagesharegrouptoken/framework_models.go index 5b9dbb7bc..603c149ea 100644 --- a/linode/consumerimagesharegrouptoken/framework_models.go +++ b/linode/consumerimagesharegrouptoken/framework_models.go @@ -2,6 +2,7 @@ package consumerimagesharegrouptoken import ( "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v3/linode/helper" @@ -84,3 +85,30 @@ func (m *ResourceModel) CopyFrom(other ResourceModel, preserveKnown bool) { m.ShareGroupUUID = helper.KeepOrUpdateValue(m.ShareGroupUUID, other.ShareGroupUUID, preserveKnown) m.ShareGroupLabel = helper.KeepOrUpdateValue(m.ShareGroupLabel, other.ShareGroupLabel, preserveKnown) } + +type DataSourceModel struct { + TokenUUID types.String `tfsdk:"token_uuid"` + Label types.String `tfsdk:"label"` + Status types.String `tfsdk:"status"` + Created timetypes.RFC3339 `tfsdk:"created"` + Updated timetypes.RFC3339 `tfsdk:"updated"` + Expiry timetypes.RFC3339 `tfsdk:"expiry"` + ValidForShareGroupUUID types.String `tfsdk:"valid_for_sharegroup_uuid"` + ShareGroupUUID types.String `tfsdk:"sharegroup_uuid"` + ShareGroupLabel types.String `tfsdk:"sharegroup_label"` +} + +func (data *DataSourceModel) ParseImageShareGroupToken(m *linodego.ImageShareGroupToken, +) diag.Diagnostics { + data.TokenUUID = types.StringValue(m.TokenUUID) + data.ValidForShareGroupUUID = types.StringValue(m.ValidForShareGroupUUID) + data.Status = types.StringValue(m.Status) + data.Label = types.StringValue(m.Label) + data.Created = timetypes.NewRFC3339TimePointerValue(m.Created) + data.Updated = timetypes.NewRFC3339TimePointerValue(m.Updated) + data.Expiry = timetypes.NewRFC3339TimePointerValue(m.Expiry) + data.ShareGroupUUID = types.StringPointerValue(m.ShareGroupUUID) + data.ShareGroupLabel = types.StringPointerValue(m.ShareGroupLabel) + + return nil +} diff --git a/linode/consumerimagesharegrouptoken/framework_models_unit_test.go b/linode/consumerimagesharegrouptoken/framework_models_unit_test.go new file mode 100644 index 000000000..b1373b43b --- /dev/null +++ b/linode/consumerimagesharegrouptoken/framework_models_unit_test.go @@ -0,0 +1,32 @@ +//go:build unit + +package consumerimagesharegrouptoken + +import ( + "testing" + + "github.com/linode/linodego" + "github.com/stretchr/testify/require" +) + +func ParseImageShareGroupToken(t *testing.T) { + m := linodego.ImageShareGroupToken{ + TokenUUID: "b1966cda-4083-4414-a140-45b78d48ec27", + Status: "active", + Label: "my-label", + ValidForShareGroupUUID: "c52b0eda-8f5b-47c0-8bea-3881a272b117", + ShareGroupUUID: linodego.Pointer("c52b0eda-8f5b-47c0-8bea-3881a272b117"), + ShareGroupLabel: linodego.Pointer("my-sg-label"), + } + + data := &DataSourceModel{} + + data.ParseImageShareGroupToken(&m) + + require.Equal(t, "b1966cda-4083-4414-a140-45b78d48ec27", data.TokenUUID.ValueString()) + require.Equal(t, "active", data.Status.ValueString()) + require.Equal(t, "my-label", data.Label.ValueString()) + require.Equal(t, "c52b0eda-8f5b-47c0-8bea-3881a272b117", data.ValidForShareGroupUUID.ValueString()) + require.Equal(t, "c52b0eda-8f5b-47c0-8bea-3881a272b117", data.ShareGroupUUID.ValueString()) + require.Equal(t, "my-sg-label", data.ShareGroupLabel.ValueString()) +} diff --git a/linode/consumerimagesharegrouptoken/framework_schema_datasource.go b/linode/consumerimagesharegrouptoken/framework_schema_datasource.go new file mode 100644 index 000000000..1d26cfd9d --- /dev/null +++ b/linode/consumerimagesharegrouptoken/framework_schema_datasource.go @@ -0,0 +1,52 @@ +package consumerimagesharegrouptoken + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +var Attributes = map[string]schema.Attribute{ + "token_uuid": schema.StringAttribute{ + Description: "The UUID of the token.", + Required: true, + }, + "label": schema.StringAttribute{ + Description: "The label of the token.", + Computed: true, + }, + "status": schema.StringAttribute{ + Description: "The status of the token.", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "When this token was created.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "updated": schema.StringAttribute{ + Description: "When this token was last updated.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "expiry": schema.StringAttribute{ + Description: "When this token will expire.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "valid_for_sharegroup_uuid": schema.StringAttribute{ + Description: "The UUID of the Image Share Group this token is for.", + Computed: true, + }, + "sharegroup_uuid": schema.StringAttribute{ + Description: "The UUID of the Image Share Group this token is for.", + Computed: true, + }, + "sharegroup_label": schema.StringAttribute{ + Description: "The label of the Image Share Group this token is for.", + Computed: true, + }, +} + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: Attributes, +} diff --git a/linode/consumerimagesharegrouptoken/tmpl/data_basic.gotf b/linode/consumerimagesharegrouptoken/tmpl/data_basic.gotf new file mode 100644 index 000000000..201e5d419 --- /dev/null +++ b/linode/consumerimagesharegrouptoken/tmpl/data_basic.gotf @@ -0,0 +1,10 @@ +{{ define "consumer_image_share_group_token_data_basic" }} + +{{ template "consumer_image_share_group_token_basic" .}} + +data "linode_consumer_image_share_group_token" "foobar" { + provider = linode-consumer + token_uuid = linode_consumer_image_share_group_token.foobar.token_uuid +} + +{{ end }} \ No newline at end of file diff --git a/linode/consumerimagesharegrouptoken/tmpl/template.go b/linode/consumerimagesharegrouptoken/tmpl/template.go index 723f0e9fc..f1c1554d3 100644 --- a/linode/consumerimagesharegrouptoken/tmpl/template.go +++ b/linode/consumerimagesharegrouptoken/tmpl/template.go @@ -18,3 +18,11 @@ func Basic(t testing.TB, shareGroupLabel, tokenLabel string) string { TokenLabel: tokenLabel, }) } + +func DataBasic(t testing.TB, shareGroupLabel, tokenLabel string) string { + return acceptance.ExecuteTemplate(t, + "consumer_image_share_group_token_data_basic", TemplateData{ + ShareGroupLabel: shareGroupLabel, + TokenLabel: tokenLabel, + }) +} diff --git a/linode/framework_provider.go b/linode/framework_provider.go index 7c1f699f3..21723be74 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -346,5 +346,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource producerimagesharegroupimageshares.NewDataSource, producerimagesharegroupmember.NewDataSource, producerimagesharegroupmembers.NewDataSource, + consumerimagesharegrouptoken.NewDataSource, } } From 8691552e52fb2c82e5b8123ec558d0b01c39d148 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Wed, 29 Oct 2025 11:17:24 -0400 Subject: [PATCH 18/29] Added ImageShareGroupTokens datasource --- .../consumer_image_share_group_token.md | 2 +- .../consumer_image_share_group_tokens.md | 83 ++++++++++++++ .../producer_image_share_group_members.md | 2 +- .../datasource_test.go | 103 ++++++++++++++++++ .../framework_datasource.go | 78 +++++++++++++ .../framework_models.go | 31 ++++++ .../framework_schema_datasource.go | 36 ++++++ .../tmpl/data_basic.gotf | 57 ++++++++++ .../tmpl/template.go | 20 ++++ linode/framework_provider.go | 2 + .../framework_datasource.go | 4 +- 11 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 docs/data-sources/consumer_image_share_group_tokens.md create mode 100644 linode/consumerimagesharegrouptokens/datasource_test.go create mode 100644 linode/consumerimagesharegrouptokens/framework_datasource.go create mode 100644 linode/consumerimagesharegrouptokens/framework_models.go create mode 100644 linode/consumerimagesharegrouptokens/framework_schema_datasource.go create mode 100644 linode/consumerimagesharegrouptokens/tmpl/data_basic.gotf create mode 100644 linode/consumerimagesharegrouptokens/tmpl/template.go diff --git a/docs/data-sources/consumer_image_share_group_token.md b/docs/data-sources/consumer_image_share_group_token.md index 9e7d37327..6c62fa5b5 100644 --- a/docs/data-sources/consumer_image_share_group_token.md +++ b/docs/data-sources/consumer_image_share_group_token.md @@ -23,7 +23,7 @@ data "linode_consumer_image_share_group_token" "token" { The following arguments are supported: -* `token_uuid` - The UUID of the token. +* `token_uuid` - (Required) The UUID of the token. ## Attributes Reference diff --git a/docs/data-sources/consumer_image_share_group_tokens.md b/docs/data-sources/consumer_image_share_group_tokens.md new file mode 100644 index 000000000..2438f3424 --- /dev/null +++ b/docs/data-sources/consumer_image_share_group_tokens.md @@ -0,0 +1,83 @@ +--- +page_title: "Linode: linode_consumer_image_share_group_tokens" +description: |- + Lists Image Share Group Tokens on your account. +--- + +# Data Source: linode\_consumer\_image\_share\_group\_tokens + +Provides information about a list of Image Share Group Tokens that match a set of filters. +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +The following example shows how one might use this data source to list Image Share Groups. + +```hcl +data "linode_consumer_image_share_group_tokens" "all" {} + +data "linode_consumer_image_share_group_tokens" "filtered" { + filter { + name = "label" + values = ["my-label"] + } +} + +output "all-share-group-tokens" { + value = data.linode_consumer_image_share_group_tokens.all.tokens +} + +output "filtered-share-group-tokens" { + value = data.linode_consumer_image_share_group_tokens.filtered.tokens +} +``` + +## Argument Reference + +The following arguments are supported: + +* [`filter`](#filter) - (Optional) A set of filters used to select Image Share Groups that meet certain requirements. + +### Filter + +* `name` - (Required) The name of the field to filter by. See the [Filterable Fields section](#filterable-fields) for a complete list of filterable fields. + +* `values` - (Required) A list of values for the filter to allow. These values should all be in string form. + +* `match_by` - (Optional) The method to match the field by. (`exact`, `regex`, `substring`; default `exact`) + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `token_uuid` - The UUID of the token. + +* `label` - A label for the token. + +* `status` - The status of the token. + +* `created` - When the token was created. + +* `updated` - When the token was last updated. + +* `expiry` - When the token will expire. + +* `valid_for_sharegroup_uuid` - The UUID of the Image Share Group for which to create a token. + +* `sharegroup_uuid` - The UUID of the Image Share Group that the token is for. + +* `sharegroup_label` - The label of the Image Share Group that the token is for. + +## Filterable Fields + +* `token_uuid` + +* `label` + +* `status` + +* `valid_for_sharegroup_uuid` + +* `sharegroup_uuid` + +* `sharegroup_label` diff --git a/docs/data-sources/producer_image_share_group_members.md b/docs/data-sources/producer_image_share_group_members.md index 1cb4162a5..c2af8aac6 100644 --- a/docs/data-sources/producer_image_share_group_members.md +++ b/docs/data-sources/producer_image_share_group_members.md @@ -11,7 +11,7 @@ For more information, see the [Linode APIv4 docs](TODO). ## Example Usage -The following example shows how one might use this data source to list Image Share Groups. +The following example shows how one might use this data source to list Image Share Group Members. ```hcl data "linode_producer_image_share_group_members" "all" { diff --git a/linode/consumerimagesharegrouptokens/datasource_test.go b/linode/consumerimagesharegrouptokens/datasource_test.go new file mode 100644 index 000000000..cedd42ffa --- /dev/null +++ b/linode/consumerimagesharegrouptokens/datasource_test.go @@ -0,0 +1,103 @@ +//go:build integration || consumerimagesharegrouptokens + +package consumerimagesharegrouptokens_test + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptokens/tmpl" +) + +// This test requires two separate Linode API tokens, one for the producer +// and one for the consumer. +// +// These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN +// environment variables. +// +// If either is not set,the test will be skipped. +func TestAccDataSourceImageShareGroupTokens_basic(t *testing.T) { + t.Parallel() + + const dsByLabel = "data.linode_consumer_image_share_group_tokens.by_label" + const dsByStatus = "data.linode_consumer_image_share_group_tokens.by_status" + const dsByTokenUUID = "data.linode_consumer_image_share_group_tokens.by_token_uuid" + const dsByValidForShareGroupUUID = "data.linode_consumer_image_share_group_tokens.by_valid_for_sharegroup_uuid" + + producerToken := os.Getenv("LINODE_PRODUCER_TOKEN") + consumerToken := os.Getenv("LINODE_CONSUMER_TOKEN") + + if producerToken == "" || consumerToken == "" { + t.Skip("Skipping test: both LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN must be set") + } + + producerClient, err := acceptance.GetTestClientAlternateToken("LINODE_PRODUCER_TOKEN") + if err != nil { + t.Fatalf("Failed to create producer client: %s", err) + } + + consumerClient, err := acceptance.GetTestClientAlternateToken("LINODE_CONSUMER_TOKEN") + if err != nil { + t.Fatalf("Failed to create consumer client: %s", err) + } + + producerProvider := acceptance.NewFrameworkProviderWithClient(producerClient) + consumerProvider := acceptance.NewFrameworkProviderWithClient(consumerClient) + + shareGroupLabel := acctest.RandomWithPrefix("tf-test") + tokenLabel := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "linode-producer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](producerProvider) + }, + "linode-consumer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](consumerProvider) + }, + }, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, shareGroupLabel, tokenLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dsByStatus, "tokens.#", "1"), + resource.TestCheckResourceAttrSet(dsByStatus, "tokens.0.token_uuid"), + resource.TestCheckResourceAttrSet(dsByStatus, "tokens.0.status"), + resource.TestCheckResourceAttrSet(dsByStatus, "tokens.0.valid_for_sharegroup_uuid"), + resource.TestCheckNoResourceAttr(dsByStatus, "tokens.0.sharegroup_uuid"), + resource.TestCheckNoResourceAttr(dsByStatus, "tokens.0.sharegroup_label"), + resource.TestCheckResourceAttr(dsByStatus, "tokens.0.label", tokenLabel), + + resource.TestCheckResourceAttr(dsByLabel, "tokens.#", "1"), + resource.TestCheckResourceAttrSet(dsByLabel, "tokens.0.token_uuid"), + resource.TestCheckResourceAttrSet(dsByLabel, "tokens.0.status"), + resource.TestCheckResourceAttrSet(dsByLabel, "tokens.0.valid_for_sharegroup_uuid"), + resource.TestCheckNoResourceAttr(dsByLabel, "tokens.0.sharegroup_uuid"), + resource.TestCheckNoResourceAttr(dsByLabel, "tokens.0.sharegroup_label"), + resource.TestCheckResourceAttr(dsByLabel, "tokens.0.label", tokenLabel), + + resource.TestCheckResourceAttr(dsByTokenUUID, "tokens.#", "1"), + resource.TestCheckResourceAttrSet(dsByTokenUUID, "tokens.0.token_uuid"), + resource.TestCheckResourceAttrSet(dsByTokenUUID, "tokens.0.status"), + resource.TestCheckResourceAttrSet(dsByTokenUUID, "tokens.0.valid_for_sharegroup_uuid"), + resource.TestCheckNoResourceAttr(dsByTokenUUID, "tokens.0.sharegroup_uuid"), + resource.TestCheckNoResourceAttr(dsByTokenUUID, "tokens.0.sharegroup_label"), + resource.TestCheckResourceAttr(dsByTokenUUID, "tokens.0.label", tokenLabel), + + resource.TestCheckResourceAttr(dsByValidForShareGroupUUID, "tokens.#", "1"), + resource.TestCheckResourceAttrSet(dsByValidForShareGroupUUID, "tokens.0.token_uuid"), + resource.TestCheckResourceAttrSet(dsByValidForShareGroupUUID, "tokens.0.status"), + resource.TestCheckResourceAttrSet(dsByValidForShareGroupUUID, "tokens.0.valid_for_sharegroup_uuid"), + resource.TestCheckNoResourceAttr(dsByValidForShareGroupUUID, "tokens.0.sharegroup_uuid"), + resource.TestCheckNoResourceAttr(dsByValidForShareGroupUUID, "tokens.0.sharegroup_label"), + resource.TestCheckResourceAttr(dsByValidForShareGroupUUID, "tokens.0.label", tokenLabel), + ), + }, + }, + }) +} diff --git a/linode/consumerimagesharegrouptokens/framework_datasource.go b/linode/consumerimagesharegrouptokens/framework_datasource.go new file mode 100644 index 000000000..20da8ae5f --- /dev/null +++ b/linode/consumerimagesharegrouptokens/framework_datasource.go @@ -0,0 +1,78 @@ +package consumerimagesharegrouptokens + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_consumer_image_share_group_tokens", + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (r *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+r.Config.Name) + + var data ImageShareGroupTokenFilterModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + id, d := filterConfig.GenerateID(data.Filters) + if d != nil { + resp.Diagnostics.Append(d) + return + } + data.ID = id + + result, d := filterConfig.GetAndFilter( + ctx, + r.Meta.Client, + data.Filters, + listImageShareGroupTokens, + data.Order, + data.OrderBy, + ) + if d != nil { + resp.Diagnostics.Append(d) + return + } + + data.ParseImageShareGroupTokens(helper.AnySliceToTyped[linodego.ImageShareGroupToken](result)) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func listImageShareGroupTokens(ctx context.Context, client *linodego.Client, filter string) ([]any, error) { + tokens, err := client.ImageShareGroupListTokens(ctx, &linodego.ListOptions{ + Filter: filter, + }) + if err != nil { + return nil, err + } + + return helper.TypedSliceToAny(tokens), nil +} diff --git a/linode/consumerimagesharegrouptokens/framework_models.go b/linode/consumerimagesharegrouptokens/framework_models.go new file mode 100644 index 000000000..2efe2a8ab --- /dev/null +++ b/linode/consumerimagesharegrouptokens/framework_models.go @@ -0,0 +1,31 @@ +package consumerimagesharegrouptokens + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +type ImageShareGroupTokenFilterModel struct { + ID types.String `tfsdk:"id"` + Filters frameworkfilter.FiltersModelType `tfsdk:"filter"` + Order types.String `tfsdk:"order"` + OrderBy types.String `tfsdk:"order_by"` + Tokens []consumerimagesharegrouptoken.DataSourceModel `tfsdk:"tokens"` +} + +func (model *ImageShareGroupTokenFilterModel) ParseImageShareGroupTokens( + tokens []linodego.ImageShareGroupToken, +) { + tokenModels := make([]consumerimagesharegrouptoken.DataSourceModel, len(tokens)) + + for i, token := range tokens { + var tokenModel consumerimagesharegrouptoken.DataSourceModel + tokenModel.ParseImageShareGroupToken(&token) + tokenModels[i] = tokenModel + + } + + model.Tokens = tokenModels +} diff --git a/linode/consumerimagesharegrouptokens/framework_schema_datasource.go b/linode/consumerimagesharegrouptokens/framework_schema_datasource.go new file mode 100644 index 000000000..0a5182afe --- /dev/null +++ b/linode/consumerimagesharegrouptokens/framework_schema_datasource.go @@ -0,0 +1,36 @@ +package consumerimagesharegrouptokens + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +var filterConfig = frameworkfilter.Config{ + "token_uuid": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeString}, + "label": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "status": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "valid_for_sharegroup_uuid": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "sharegroup_uuid": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "sharegroup_label": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, +} + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The data source's unique ID.", + Computed: true, + }, + "order": filterConfig.OrderSchema(), + "order_by": filterConfig.OrderBySchema(), + }, + Blocks: map[string]schema.Block{ + "filter": filterConfig.Schema(), + "tokens": schema.ListNestedBlock{ + Description: "The returned list of Image Share Group Tokens.", + NestedObject: schema.NestedBlockObject{ + Attributes: consumerimagesharegrouptoken.Attributes, + }, + }, + }, +} diff --git a/linode/consumerimagesharegrouptokens/tmpl/data_basic.gotf b/linode/consumerimagesharegrouptokens/tmpl/data_basic.gotf new file mode 100644 index 000000000..05290921b --- /dev/null +++ b/linode/consumerimagesharegrouptokens/tmpl/data_basic.gotf @@ -0,0 +1,57 @@ +{{ define "consumer_image_share_group_tokens_data_basic" }} + +resource "linode_producer_image_share_group" "foobar" { + provider = linode-producer + label = "{{ .ShareGroupLabel }}" + description = "Example description" +} + +resource "linode_consumer_image_share_group_token" "foobar" { + provider = linode-consumer + depends_on = [linode_producer_image_share_group.foobar] + valid_for_sharegroup_uuid = linode_producer_image_share_group.foobar.uuid + label = "{{ .TokenLabel }}" +} + +data "linode_consumer_image_share_group_tokens" "by_label" { + depends_on = [linode_consumer_image_share_group_token.foobar] + provider = linode-consumer + + filter { + name = "label" + values = [linode_consumer_image_share_group_token.foobar.label] + } +} + +data "linode_consumer_image_share_group_tokens" "by_status" { + depends_on = [linode_consumer_image_share_group_token.foobar] + provider = linode-consumer + + filter { + name = "status" + values = [linode_consumer_image_share_group_token.foobar.status] + } +} + +data "linode_consumer_image_share_group_tokens" "by_token_uuid" { + depends_on = [linode_consumer_image_share_group_token.foobar] + provider = linode-consumer + + filter { + name = "token_uuid" + values = [linode_consumer_image_share_group_token.foobar.token_uuid] + } +} + +data "linode_consumer_image_share_group_tokens" "by_valid_for_sharegroup_uuid" { + depends_on = [linode_consumer_image_share_group_token.foobar] + provider = linode-consumer + + filter { + name = "valid_for_sharegroup_uuid" + values = [linode_consumer_image_share_group_token.foobar.valid_for_sharegroup_uuid] + } +} + + +{{ end }} \ No newline at end of file diff --git a/linode/consumerimagesharegrouptokens/tmpl/template.go b/linode/consumerimagesharegrouptokens/tmpl/template.go new file mode 100644 index 000000000..ef11db000 --- /dev/null +++ b/linode/consumerimagesharegrouptokens/tmpl/template.go @@ -0,0 +1,20 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + ShareGroupLabel string + TokenLabel string +} + +func DataBasic(t testing.TB, shareGroupLabel, tokenLabel string) string { + return acceptance.ExecuteTemplate(t, + "consumer_image_share_group_tokens_data_basic", TemplateData{ + ShareGroupLabel: shareGroupLabel, + TokenLabel: tokenLabel, + }) +} diff --git a/linode/framework_provider.go b/linode/framework_provider.go index 21723be74..ed6fac5f4 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -17,6 +17,7 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/childaccount" "github.com/linode/terraform-provider-linode/v3/linode/childaccounts" "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptokens" "github.com/linode/terraform-provider-linode/v3/linode/databasebackups" "github.com/linode/terraform-provider-linode/v3/linode/databaseengines" "github.com/linode/terraform-provider-linode/v3/linode/databasemysql" @@ -347,5 +348,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource producerimagesharegroupmember.NewDataSource, producerimagesharegroupmembers.NewDataSource, consumerimagesharegrouptoken.NewDataSource, + consumerimagesharegrouptokens.NewDataSource, } } diff --git a/linode/producerimagesharegroupmembers/framework_datasource.go b/linode/producerimagesharegroupmembers/framework_datasource.go index 737b76925..f704a1d19 100644 --- a/linode/producerimagesharegroupmembers/framework_datasource.go +++ b/linode/producerimagesharegroupmembers/framework_datasource.go @@ -85,7 +85,7 @@ func listWrapper( ) ([]any, error) { tflog.Trace(ctx, "client.ImageShareGroupListMembers(...)") - nbs, err := client.ImageShareGroupListMembers( + members, err := client.ImageShareGroupListMembers( ctx, shareGroupID, &linodego.ListOptions{ @@ -96,6 +96,6 @@ func listWrapper( return nil, err } - return helper.TypedSliceToAny(nbs), nil + return helper.TypedSliceToAny(members), nil } } From ffafd4aa7e8b445170cdf6d433cdf130d344b774 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Wed, 29 Oct 2025 14:56:35 -0400 Subject: [PATCH 19/29] Added datasource for Consumer Image Share Group --- .../consumer_image_share_group.md | 42 +++++++++++ .../datasource_test.go | 75 +++++++++++++++++++ .../framework_datasource.go | 56 ++++++++++++++ .../framework_models.go | 34 +++++++++ .../framework_schema_datasource.go | 45 +++++++++++ .../tmpl/data_basic.gotf | 30 ++++++++ .../consumerimagesharegroup/tmpl/template.go | 22 ++++++ linode/framework_provider.go | 2 + 8 files changed, 306 insertions(+) create mode 100644 docs/data-sources/consumer_image_share_group.md create mode 100644 linode/consumerimagesharegroup/datasource_test.go create mode 100644 linode/consumerimagesharegroup/framework_datasource.go create mode 100644 linode/consumerimagesharegroup/framework_models.go create mode 100644 linode/consumerimagesharegroup/framework_schema_datasource.go create mode 100644 linode/consumerimagesharegroup/tmpl/data_basic.gotf create mode 100644 linode/consumerimagesharegroup/tmpl/template.go diff --git a/docs/data-sources/consumer_image_share_group.md b/docs/data-sources/consumer_image_share_group.md new file mode 100644 index 000000000..2e00dfaec --- /dev/null +++ b/docs/data-sources/consumer_image_share_group.md @@ -0,0 +1,42 @@ +--- +page_title: "Linode: linode_consumer_image_share_group" +description: |- + Provides details about an Image Share Group a consumer's token has been accepted into. +--- + +# Data Source: linode\_consumer\_image\_share\_group + +`linode_consumer_image_share_group` provides details about an Image Share Group that the user's token has been accepted into. +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +The following example shows how the datasource might be used to obtain additional information about an Image Share Group. + +```hcl +data "linode_consumer_image_share_group" "sg" { + token_uuid = "7548d17e-8db4-4a91-b47c-a8e1203063d9" +} +``` + +## Argument Reference + +* `token_uuid` - (Required) The UUID of the token that has been accepted into the Image Share Group. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The ID of the Image Share Group. + +* `uuid` - The UUID of the Image Share Group. + +* `label` - The label of the Image Share Group. + +* `description` - The description of the Image Share Group. + +* `is_suspended` - Whether the Image Share Group is suspended. + +* `created` - The date and time the Image Share Group was created. + +* `updated` - The date and time the Image Share Group was last updated. diff --git a/linode/consumerimagesharegroup/datasource_test.go b/linode/consumerimagesharegroup/datasource_test.go new file mode 100644 index 000000000..12a839f6d --- /dev/null +++ b/linode/consumerimagesharegroup/datasource_test.go @@ -0,0 +1,75 @@ +//go:build integration || consumerimagesharegroup + +package consumerimagesharegroup_test + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegroup/tmpl" +) + +// This test requires two separate Linode API tokens, one for the producer +// and one for the consumer. +// +// These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN +// environment variables. +// +// If either is not set,the test will be skipped. +func TestAccDataSourceConsumerImageShareGroup_basic(t *testing.T) { + t.Parallel() + + producerToken := os.Getenv("LINODE_PRODUCER_TOKEN") + consumerToken := os.Getenv("LINODE_CONSUMER_TOKEN") + + if producerToken == "" || consumerToken == "" { + t.Skip("Skipping test: both LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN must be set") + } + + producerClient, err := acceptance.GetTestClientAlternateToken("LINODE_PRODUCER_TOKEN") + if err != nil { + t.Fatalf("Failed to create producer client: %s", err) + } + + consumerClient, err := acceptance.GetTestClientAlternateToken("LINODE_CONSUMER_TOKEN") + if err != nil { + t.Fatalf("Failed to create consumer client: %s", err) + } + + producerProvider := acceptance.NewFrameworkProviderWithClient(producerClient) + consumerProvider := acceptance.NewFrameworkProviderWithClient(consumerClient) + + resourceName := "data.linode_consumer_image_share_group.foobar" + shareGroupLabel := acctest.RandomWithPrefix("tf-test") + tokenLabel := acctest.RandomWithPrefix("tf-test") + memberLabel := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "linode-producer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](producerProvider) + }, + "linode-consumer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](consumerProvider) + }, + }, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, shareGroupLabel, tokenLabel, memberLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "token_uuid"), + resource.TestCheckResourceAttrSet(resourceName, "id"), + resource.TestCheckResourceAttrSet(resourceName, "uuid"), + resource.TestCheckResourceAttr(resourceName, "label", shareGroupLabel), + resource.TestCheckResourceAttrSet(resourceName, "description"), + resource.TestCheckResourceAttrSet(resourceName, "is_suspended"), + ), + }, + }, + }) +} diff --git a/linode/consumerimagesharegroup/framework_datasource.go b/linode/consumerimagesharegroup/framework_datasource.go new file mode 100644 index 000000000..1be81acd9 --- /dev/null +++ b/linode/consumerimagesharegroup/framework_datasource.go @@ -0,0 +1,56 @@ +package consumerimagesharegroup + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_consumer_image_share_group", + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (d *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+d.Config.Name) + + client := d.Meta.Client + + var data DataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tokenUUID := data.TokenUUID.ValueString() + + tflog.Trace(ctx, "client.ImageShareGroupGetByToken(...)") + m, err := client.ImageShareGroupGetByToken(ctx, tokenUUID) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Unable to get Image Share Group shared with %s", tokenUUID), err.Error(), + ) + return + } + + data.ParseConsumerImageShareGroup(m) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/linode/consumerimagesharegroup/framework_models.go b/linode/consumerimagesharegroup/framework_models.go new file mode 100644 index 000000000..d9c01b7ca --- /dev/null +++ b/linode/consumerimagesharegroup/framework_models.go @@ -0,0 +1,34 @@ +package consumerimagesharegroup + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" +) + +type DataSourceModel struct { + TokenUUID types.String `tfsdk:"token_uuid"` + ID types.Int64 `tfsdk:"id"` + UUID types.String `tfsdk:"uuid"` + Label types.String `tfsdk:"label"` + Description types.String `tfsdk:"description"` + IsSuspended types.Bool `tfsdk:"is_suspended"` + Created timetypes.RFC3339 `tfsdk:"created"` + Updated timetypes.RFC3339 `tfsdk:"updated"` +} + +func (data *DataSourceModel) ParseConsumerImageShareGroup(m *linodego.ConsumerImageShareGroup, +) diag.Diagnostics { + // Do not touch TokenUUID as it is not returned by the API and must be preserved + + data.ID = types.Int64Value(int64(m.ID)) + data.UUID = types.StringValue(m.UUID) + data.Label = types.StringValue(m.Label) + data.Description = types.StringValue(m.Description) + data.IsSuspended = types.BoolValue(m.IsSuspended) + data.Created = timetypes.NewRFC3339TimePointerValue(m.Created) + data.Updated = timetypes.NewRFC3339TimePointerValue(m.Updated) + + return nil +} diff --git a/linode/consumerimagesharegroup/framework_schema_datasource.go b/linode/consumerimagesharegroup/framework_schema_datasource.go new file mode 100644 index 000000000..a7ac37386 --- /dev/null +++ b/linode/consumerimagesharegroup/framework_schema_datasource.go @@ -0,0 +1,45 @@ +package consumerimagesharegroup + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "token_uuid": schema.StringAttribute{ + Description: "The UUID of the token that has been accepted into this Image Share Group.", + Required: true, + }, + "id": schema.Int64Attribute{ + Description: "The id of the Image Share Group.", + Computed: true, + }, + "uuid": schema.StringAttribute{ + Description: "The uuid of the Image Share Group.", + Computed: true, + }, + "label": schema.StringAttribute{ + Description: "The label of the Image Share Group.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "The description of the Image Share Group.", + Computed: true, + }, + "is_suspended": schema.BoolAttribute{ + Description: "Whether or not the Image Share Group is suspended..", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "When the Image Share Group was created.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "updated": schema.StringAttribute{ + Description: "When the Image Share Group was last updated.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + }, +} diff --git a/linode/consumerimagesharegroup/tmpl/data_basic.gotf b/linode/consumerimagesharegroup/tmpl/data_basic.gotf new file mode 100644 index 000000000..8a318a98e --- /dev/null +++ b/linode/consumerimagesharegroup/tmpl/data_basic.gotf @@ -0,0 +1,30 @@ +{{ define "consumer_image_share_group_data_basic" }} + +resource "linode_producer_image_share_group" "foobar" { + provider = linode-producer + label = "{{ .ShareGroupLabel }}" + description = "Example description" +} + +resource "linode_consumer_image_share_group_token" "foobar" { + provider = linode-consumer + depends_on = [linode_producer_image_share_group.foobar] + valid_for_sharegroup_uuid = linode_producer_image_share_group.foobar.uuid + label = "{{ .TokenLabel }}" +} + +resource "linode_producer_image_share_group_member" "foobar" { + provider = linode-producer + depends_on = [linode_producer_image_share_group.foobar, linode_consumer_image_share_group_token.foobar] + sharegroup_id = linode_producer_image_share_group.foobar.id + token = linode_consumer_image_share_group_token.foobar.token + label = "{{ .MemberLabel }}" +} + +data "linode_consumer_image_share_group" "foobar" { + provider = linode-consumer + depends_on = [linode_producer_image_share_group_member.foobar] + token_uuid = linode_consumer_image_share_group_token.foobar.token_uuid +} + +{{ end }} \ No newline at end of file diff --git a/linode/consumerimagesharegroup/tmpl/template.go b/linode/consumerimagesharegroup/tmpl/template.go new file mode 100644 index 000000000..013ad1f7a --- /dev/null +++ b/linode/consumerimagesharegroup/tmpl/template.go @@ -0,0 +1,22 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + ShareGroupLabel string + TokenLabel string + MemberLabel string +} + +func DataBasic(t testing.TB, shareGroupLabel, tokenLabel, memberLabel string) string { + return acceptance.ExecuteTemplate(t, + "consumer_image_share_group_data_basic", TemplateData{ + ShareGroupLabel: shareGroupLabel, + TokenLabel: tokenLabel, + MemberLabel: memberLabel, + }) +} diff --git a/linode/framework_provider.go b/linode/framework_provider.go index ed6fac5f4..be53569b8 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -16,6 +16,7 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/backup" "github.com/linode/terraform-provider-linode/v3/linode/childaccount" "github.com/linode/terraform-provider-linode/v3/linode/childaccounts" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegroup" "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken" "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptokens" "github.com/linode/terraform-provider-linode/v3/linode/databasebackups" @@ -349,5 +350,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource producerimagesharegroupmembers.NewDataSource, consumerimagesharegrouptoken.NewDataSource, consumerimagesharegrouptokens.NewDataSource, + consumerimagesharegroup.NewDataSource, } } From 69032cd347783f17e9bf5b7b739b357692eed886 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 30 Oct 2025 11:54:29 -0400 Subject: [PATCH 20/29] Added ConsumerImageShareGroupImageShares datasource --- ...consumer_image_share_group_image_shares.md | 95 ++++++++++++ ...producer_image_share_group_image_shares.md | 5 +- .../datasource_test.go | 91 ++++++++++++ .../framework_datasource.go | 95 ++++++++++++ .../framework_models.go | 136 ++++++++++++++++++ .../framework_schema_datasource.go | 130 +++++++++++++++++ .../tmpl/data_basic.gotf | 133 +++++++++++++++++ .../tmpl/template.go | 32 +++++ linode/framework_provider.go | 2 + .../framework_schema_datasource.go | 2 +- 10 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 docs/data-sources/consumer_image_share_group_image_shares.md create mode 100644 linode/consumerimagesharegroupimageshares/datasource_test.go create mode 100644 linode/consumerimagesharegroupimageshares/framework_datasource.go create mode 100644 linode/consumerimagesharegroupimageshares/framework_models.go create mode 100644 linode/consumerimagesharegroupimageshares/framework_schema_datasource.go create mode 100644 linode/consumerimagesharegroupimageshares/tmpl/data_basic.gotf create mode 100644 linode/consumerimagesharegroupimageshares/tmpl/template.go diff --git a/docs/data-sources/consumer_image_share_group_image_shares.md b/docs/data-sources/consumer_image_share_group_image_shares.md new file mode 100644 index 000000000..08230f5df --- /dev/null +++ b/docs/data-sources/consumer_image_share_group_image_shares.md @@ -0,0 +1,95 @@ +--- +page_title: "Linode: linode_consumer_image_share_group_image_shares" +description: |- + Lists Images shared in the Image Share Group the provided Token has been accepted into. +--- + +# Data Source: linode\_consumer\_image\_share\_group\_image\_shares + +Provides information about a list of Images that match a set of filters that have been +.shared in the Image Share Group that the provided Token has been accepted into +For more information, see the [Linode APIv4 docs](TODO). + +## Example Usage + +The following example shows how one might use this data source to list Images shared in an Image Share Group. + +```hcl +data "linode_consumer_image_share_group_image_shares" "all" {} + +data "linode_consumer_image_share_group_image_shares" "filtered" { + token_uuid = "54e1adf3-e499-4685-82be-10d29d4e8fae" + filter { + name = "label" + values = ["my-label"] + } +} + +output "all-shared-images" { + value = data.linode_consumer_image_share_group_image_shares.all.image_shares +} + +output "filtered-shared-images" { + value = data.linode_consumer_image_share_group_image_shares.filtered.image_shares +} +``` + +## Argument Reference + +The following arguments are supported: + +* `token_uuid` - (Required) The UUID of the Token that has been accepted into the Image Share Group to list shared Images from. + +* [`filter`](#filter) - (Optional) A set of filters used to select Image Share Groups that meet certain requirements. + +### Filter + +* `name` - (Required) The name of the field to filter by. See the [Filterable Fields section](#filterable-fields) for a complete list of filterable fields. + +* `values` - (Required) A list of values for the filter to allow. These values should all be in string form. + +* `match_by` - (Optional) The method to match the field by. (`exact`, `regex`, `substring`; default `exact`) + +## Attributes Reference + +Each Image Share will be stored in the `images_shares` attribute and will export the following attributes: + +* `id` - The unique ID assigned to this Image Share. + +* `label` - The label of the Image Share. + +* `capabilities` - The capabilities of the Image represented by the Image Share. + +* `created` - When this Image Share was created. + +* `deprecated` - Whether this Image is deprecated. + +* `description` - A description of the Image Share. + +* `is_public` - True if the Image is public. + +* `image_sharing` - Details about image sharing, including who the image is shared with and by. + * `shared_with` - Details about who the image is shared with. + * `sharegroup_count` - The number of sharegroups the private image is present in. + * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. + * `shared_by` - Details about who the image is shared by. + * `sharegroup_id` - The sharegroup_id from the im_ImageShare row. + * `sharegroup_uuid` - The sharegroup_uuid from the im_ImageShare row. + * `sharegroup_label` - The label from the associated im_ImageShareGroup row. + * `source_image_id` - The image id of the base image (will only be shown to producers, will be null for consumers). + +* `size` - The minimum size this Image needs to deploy. Size is in MB. example: 2500 + +* `status` - The current status of this image. (`creating`, `pending_upload`, `available`) + +* `type` - How the Image was created. Manual Images can be created at any time. "Automatic" Images are created automatically from a deleted Linode. (`manual`, `automatic`) + +* `tags` - A list of customized tags. + +* `total_size` - The total size of the image in all available regions. + +## Filterable Fields + +* `id` + +* `label` diff --git a/docs/data-sources/producer_image_share_group_image_shares.md b/docs/data-sources/producer_image_share_group_image_shares.md index 5f56f0dde..12c019085 100644 --- a/docs/data-sources/producer_image_share_group_image_shares.md +++ b/docs/data-sources/producer_image_share_group_image_shares.md @@ -14,9 +14,12 @@ For more information, see the [Linode APIv4 docs](TODO). The following example shows how one might use this data source to list Images shared in an Image Share Group. ```hcl -data "linode_producer_image_share_group_image_shares" "all" {} +data "linode_producer_image_share_group_image_shares" "all" { + sharegroup_id = 123 +} data "linode_producer_image_share_group_image_shares" "filtered" { + sharegroup_id = 123 filter { name = "label" values = ["my-label"] diff --git a/linode/consumerimagesharegroupimageshares/datasource_test.go b/linode/consumerimagesharegroupimageshares/datasource_test.go new file mode 100644 index 000000000..67036b982 --- /dev/null +++ b/linode/consumerimagesharegroupimageshares/datasource_test.go @@ -0,0 +1,91 @@ +//go:build integration || consumerimagesharegroupimageshares + +package consumerimagesharegroupimageshares_test + +import ( + "log" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegroupimageshares/tmpl" +) + +// This test requires two separate Linode API tokens, one for the producer +// and one for the consumer. +// +// These can be set using the LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN +// environment variables. +// +// If either is not set,the test will be skipped. +func TestAccDataSourceImageShareGroupImageShares_basic(t *testing.T) { + t.Parallel() + + producerToken := os.Getenv("LINODE_PRODUCER_TOKEN") + consumerToken := os.Getenv("LINODE_CONSUMER_TOKEN") + + if producerToken == "" || consumerToken == "" { + t.Skip("Skipping test: both LINODE_PRODUCER_TOKEN and LINODE_CONSUMER_TOKEN must be set") + } + + producerClient, err := acceptance.GetTestClientAlternateToken("LINODE_PRODUCER_TOKEN") + if err != nil { + t.Fatalf("Failed to create producer client: %s", err) + } + + consumerClient, err := acceptance.GetTestClientAlternateToken("LINODE_CONSUMER_TOKEN") + if err != nil { + t.Fatalf("Failed to create consumer client: %s", err) + } + + producerProvider := acceptance.NewFrameworkProviderWithClient(producerClient) + consumerProvider := acceptance.NewFrameworkProviderWithClient(consumerClient) + + const dsAll = "data.linode_consumer_image_share_group_image_shares.all" + const dsByLabel = "data.linode_consumer_image_share_group_image_shares.by_label" + + fwLabel := acctest.RandomWithPrefix("tf_test") + instanceLabel := acctest.RandomWithPrefix("tf_test") + + instanceRegion, err := acceptance.GetRandomRegionWithCaps([]string{}, "core") + if err != nil { + log.Fatal(err) + } + + imageLabel1 := acctest.RandomWithPrefix("tf-test") + imageLabel2 := acctest.RandomWithPrefix("tf-test") + shareGroupLabel := acctest.RandomWithPrefix("tf-test") + tokenLabel := acctest.RandomWithPrefix("tf-test") + memberLabel := acctest.RandomWithPrefix("tf-test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "linode-producer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](producerProvider) + }, + "linode-consumer": func() (tfprotov6.ProviderServer, error) { + return acceptance.ProtoV6CustomProviderFactories["linode"](consumerProvider) + }, + }, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic(t, fwLabel, instanceLabel, instanceRegion, imageLabel1, imageLabel2, shareGroupLabel, tokenLabel, memberLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dsAll, "image_shares.#", "2"), + resource.TestCheckResourceAttr(dsAll, "image_shares.0.label", "image_one_label"), + resource.TestCheckResourceAttr(dsAll, "image_shares.0.description", "image one description"), + resource.TestCheckResourceAttr(dsAll, "image_shares.1.label", "image_two_label"), + resource.TestCheckResourceAttr(dsAll, "image_shares.1.description", "image two description"), + + resource.TestCheckResourceAttr(dsByLabel, "image_shares.#", "1"), + resource.TestCheckResourceAttr(dsByLabel, "image_shares.0.label", "image_two_label"), + resource.TestCheckResourceAttr(dsByLabel, "image_shares.0.description", "image two description"), + ), + }, + }, + }) +} diff --git a/linode/consumerimagesharegroupimageshares/framework_datasource.go b/linode/consumerimagesharegroupimageshares/framework_datasource.go new file mode 100644 index 000000000..f1e364134 --- /dev/null +++ b/linode/consumerimagesharegroupimageshares/framework_datasource.go @@ -0,0 +1,95 @@ +package consumerimagesharegroupimageshares + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_consumer_image_share_group_image_shares", + Schema: &frameworkDataSourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (r *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+r.Config.Name) + + var data ImageShareGroupImageShareFilterModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + id, d := filterConfig.GenerateID(data.Filters) + if d != nil { + resp.Diagnostics.Append(d) + return + } + data.ID = id + + tokenUUID := data.TokenUUID.ValueString() + + result, d := filterConfig.GetAndFilter( + ctx, + r.Meta.Client, + data.Filters, + listWrapper(tokenUUID), + data.Order, + data.OrderBy, + ) + if d != nil { + resp.Diagnostics.Append(d) + return + } + + data.parseImageShares(ctx, helper.AnySliceToTyped[linodego.ImageShareEntry](result)) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func listWrapper( + tokenUUID string, +) frameworkfilter.ListFunc { + return func( + ctx context.Context, + client *linodego.Client, + filter string, + ) ([]any, error) { + tflog.Trace(ctx, "client.ImageShareGroupGetImageShareEntriesByToken(...)") + + imageShares, err := client.ImageShareGroupGetImageShareEntriesByToken( + ctx, + tokenUUID, + &linodego.ListOptions{ + Filter: filter, + }, + ) + if err != nil { + return nil, err + } + + return helper.TypedSliceToAny(imageShares), nil + } +} diff --git a/linode/consumerimagesharegroupimageshares/framework_models.go b/linode/consumerimagesharegroupimageshares/framework_models.go new file mode 100644 index 000000000..501dca1d7 --- /dev/null +++ b/linode/consumerimagesharegroupimageshares/framework_models.go @@ -0,0 +1,136 @@ +package consumerimagesharegroupimageshares + +import ( + "context" + "time" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +type DataSourceModel struct { + ID types.String `tfsdk:"id"` + Label types.String `tfsdk:"label"` + Description types.String `tfsdk:"description"` + Capabilities []types.String `tfsdk:"capabilities"` + Created types.String `tfsdk:"created"` + Deprecated types.Bool `tfsdk:"deprecated"` + IsPublic types.Bool `tfsdk:"is_public"` + ImageSharing *ImageSharingDataSourceModel `tfsdk:"image_sharing"` + Size types.Int64 `tfsdk:"size"` + Status types.String `tfsdk:"status"` + Type types.String `tfsdk:"type"` + Tags types.List `tfsdk:"tags"` + TotalSize types.Int64 `tfsdk:"total_size"` +} + +type ImageSharingDataSourceModel struct { + SharedWith *ImageSharingSharedWithAttributesModel `tfsdk:"shared_with"` + SharedBy *ImageSharingSharedByAttributesModel `tfsdk:"shared_by"` +} + +type ImageSharingSharedWithAttributesModel struct { + ShareGroupCount types.Int64 `tfsdk:"sharegroup_count"` + ShareGroupListURL types.String `tfsdk:"sharegroup_list_url"` +} + +type ImageSharingSharedByAttributesModel struct { + ShareGroupID types.Int64 `tfsdk:"sharegroup_id"` + ShareGroupUUID types.String `tfsdk:"sharegroup_uuid"` + ShareGroupLabel types.String `tfsdk:"sharegroup_label"` + SourceImageID types.String `tfsdk:"source_image_id"` +} + +func (data *DataSourceModel) ParseImageShare( + ctx context.Context, + imageShare *linodego.ImageShareEntry, +) diag.Diagnostics { + data.ID = types.StringValue(imageShare.ID) + data.Label = types.StringValue(imageShare.Label) + + data.Description = types.StringValue(imageShare.Description) + if imageShare.Created != nil { + data.Created = types.StringValue(imageShare.Created.Format(time.RFC3339)) + } else { + data.Created = types.StringNull() + } + data.Capabilities = helper.StringSliceToFramework(imageShare.Capabilities) + data.Deprecated = types.BoolValue(imageShare.Deprecated) + data.IsPublic = types.BoolValue(imageShare.IsPublic) + data.Size = types.Int64Value(int64(imageShare.Size)) + data.Status = types.StringValue(string(imageShare.Status)) + data.Type = types.StringValue(imageShare.Type) + data.TotalSize = types.Int64Value(int64(imageShare.TotalSize)) + + tags, diags := types.ListValueFrom(ctx, types.StringType, imageShare.Tags) + if diags.HasError() { + return diags + } + data.Tags = tags + + data.ImageSharing = parseImageSharingDataSourceModel(&imageShare.ImageSharing) + + return nil +} + +func parseImageSharingDataSourceModel( + imageSharing *linodego.ImageSharing, +) *ImageSharingDataSourceModel { + if imageSharing == nil { + return nil + } + + var sharedWith *ImageSharingSharedWithAttributesModel + if sw := imageSharing.SharedWith; sw != nil { + sharedWith = &ImageSharingSharedWithAttributesModel{ + ShareGroupCount: types.Int64Value(int64(sw.ShareGroupCount)), + ShareGroupListURL: types.StringValue(sw.ShareGroupListURL), + } + } + + var sharedBy *ImageSharingSharedByAttributesModel + if sb := imageSharing.SharedBy; sb != nil { + sharedBy = &ImageSharingSharedByAttributesModel{ + ShareGroupID: types.Int64Value(int64(sb.ShareGroupID)), + ShareGroupUUID: types.StringValue(sb.ShareGroupUUID), + ShareGroupLabel: types.StringValue(sb.ShareGroupLabel), + SourceImageID: types.StringPointerValue(sb.SourceImageID), + } + } + + return &ImageSharingDataSourceModel{ + SharedWith: sharedWith, + SharedBy: sharedBy, + } +} + +type ImageShareGroupImageShareFilterModel struct { + ID types.String `tfsdk:"id"` + TokenUUID types.String `tfsdk:"token_uuid"` + Filters frameworkfilter.FiltersModelType `tfsdk:"filter"` + Order types.String `tfsdk:"order"` + OrderBy types.String `tfsdk:"order_by"` + ImageShares []DataSourceModel `tfsdk:"image_shares"` +} + +func (data *ImageShareGroupImageShareFilterModel) parseImageShares( + ctx context.Context, + imageShares []linodego.ImageShareEntry, +) diag.Diagnostics { + result := make([]DataSourceModel, len(imageShares)) + for i := range imageShares { + var imgShareData DataSourceModel + diags := imgShareData.ParseImageShare(ctx, &imageShares[i]) + if diags.HasError() { + return diags + } + result[i] = imgShareData + } + + data.ImageShares = result + + return nil +} diff --git a/linode/consumerimagesharegroupimageshares/framework_schema_datasource.go b/linode/consumerimagesharegroupimageshares/framework_schema_datasource.go new file mode 100644 index 000000000..b7c1079ef --- /dev/null +++ b/linode/consumerimagesharegroupimageshares/framework_schema_datasource.go @@ -0,0 +1,130 @@ +package consumerimagesharegroupimageshares + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/terraform-provider-linode/v3/linode/helper/frameworkfilter" +) + +var filterConfig = frameworkfilter.Config{ + "id": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeString}, + "label": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, +} + +var Attributes = map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique ID assigned to this Image Share.", + Computed: true, + }, + "label": schema.StringAttribute{ + Description: "The label of the Image Share.", + Computed: true, + }, + "capabilities": schema.SetAttribute{ + Description: "The capabilities of the Image represented by the Image Share.", + ElementType: types.StringType, + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "A description of the Image Share.", + Computed: true, + }, + "created": schema.StringAttribute{ + Description: "When this Image Share was created.", + Computed: true, + }, + "deprecated": schema.BoolAttribute{ + Description: "Whether or not this Image is deprecated.", + Computed: true, + }, + "is_public": schema.BoolAttribute{ + Description: "True if the Image is public.", + Computed: true, + }, + "image_sharing": schema.SingleNestedAttribute{ + Description: "Details about image sharing, including who the image is shared with and by.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "shared_with": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "sharegroup_count": schema.Int64Attribute{ + Description: "The number of sharegroups the private image is present in.", + Computed: true, + }, + "sharegroup_list_url": schema.StringAttribute{ + Description: "The GET api url to view the sharegroups in which the image is shared.", + Computed: true, + }, + }, + }, + "shared_by": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "sharegroup_id": schema.Int64Attribute{ + Description: "The sharegroup_id from the im_ImageShare row.", + Computed: true, + }, + "sharegroup_uuid": schema.StringAttribute{ + Description: "The sharegroup_uuid from the im_ImageShare row.", + Computed: true, + }, + "sharegroup_label": schema.StringAttribute{ + Description: "The label from the associated im_ImageShareGroup row.", + Computed: true, + }, + "source_image_id": schema.StringAttribute{ + Description: "The image id of the base image (will only be shown to producers, will be None for consumers).", + Computed: true, + }, + }, + }, + }, + }, + "size": schema.Int64Attribute{ + Description: "The minimum size this Image needs to deploy. Size is in MB.", + Computed: true, + }, + "status": schema.StringAttribute{ + Description: "The current status of this Image.", + Computed: true, + }, + "type": schema.StringAttribute{ + Description: "How the Image was created. 'Manual' Images can be created at any time. 'Automatic' " + + "images are created automatically from a deleted Linode.", + Computed: true, + }, + "tags": schema.ListAttribute{ + Description: "The customized tags for the image.", + Computed: true, + ElementType: types.StringType, + }, + "total_size": schema.Int64Attribute{ + Description: "The total size of the image in all available regions.", + Computed: true, + }, +} + +var frameworkDataSourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The data source's unique ID.", + Computed: true, + }, + "token_uuid": schema.StringAttribute{ + Description: "The UUID of the Token that has been granted access to the Images in the Image Share Group.", + Required: true, + }, + "order": filterConfig.OrderSchema(), + "order_by": filterConfig.OrderBySchema(), + }, + Blocks: map[string]schema.Block{ + "filter": filterConfig.Schema(), + "image_shares": schema.ListNestedBlock{ + Description: "The returned list of Image Shares.", + NestedObject: schema.NestedBlockObject{ + Attributes: Attributes, + }, + }, + }, +} diff --git a/linode/consumerimagesharegroupimageshares/tmpl/data_basic.gotf b/linode/consumerimagesharegroupimageshares/tmpl/data_basic.gotf new file mode 100644 index 000000000..f6f078567 --- /dev/null +++ b/linode/consumerimagesharegroupimageshares/tmpl/data_basic.gotf @@ -0,0 +1,133 @@ +{{ define "consumer_image_share_group_image_shares_data_basic" }} + +variable "ipv4_addr" { + description = "Public IPv4 address" + type = string +} + +variable "ipv6_addr" { + description = "Public IPv6 address" + type = string +} + +output "ipv4_addr" { + value = var.ipv4_addr +} + +output "ipv6_addr" { + value = var.ipv6_addr +} + +locals { + valid_ipv4_pattern = "^(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[0-1]?[0-9][0-9]?)$$" + valid_ipv6_pattern = "^(?:[a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4}|(?:[a-fA-F0-9]{1,4}:){1,7}:|(?:[a-fA-F0-9]{1,4}:){1,6}:[a-fA-F0-9]{1,4}|(?:[a-fA-F0-9]{1,4}:){1,5}:(?:[a-fA-F0-9]{1,4}:){1,4}|(?:[a-fA-F0-9]{1,4}:){1,4}:(?:[a-fA-F0-9]{1,4}:){1,4}|(?:[a-fA-F0-9]{1,4}:){1,3}:(?:[a-fA-F0-9]{1,4}:){1,4}|(?:[a-fA-F0-9]{1,4}:){1,2}:(?:[a-fA-F0-9]{1,4}:){1,4}|[a-fA-F0-9]{1,4}:(?:[a-fA-F0-9]{1,4}:){1,4}|(?:[a-fA-F0-9]{1,4}:){1,4}:|:(?::[a-fA-F0-9]{1,4}){1,7}|::|(?:[a-fA-F0-9]{1,4}:){1,7}(?::[a-fA-F0-9]{1,4}){1,7}$$" + valid_ipv4 = can(regex(local.valid_ipv4_pattern, var.ipv4_addr)) + valid_ipv6 = can(regex(local.valid_ipv6_pattern, var.ipv6_addr)) + ipv4_address = local.valid_ipv4 ? "${var.ipv4_addr}/32" : null + ipv6_address = local.valid_ipv6 ? "${var.ipv6_addr}/128" : null +} + +resource "linode_firewall" "e2e_test_firewall" { + provider = linode-producer + label = "{{.FirewallLabel}}" + outbound_policy = "ACCEPT" + inbound_policy = "DROP" + + dynamic "inbound" { + for_each = local.valid_ipv4 || local.valid_ipv6 ? [1] : [] + content { + label = "tcp_inbound_ssh_accept_local" + action = "ACCEPT" + ipv4 = local.ipv4_address != null ? [local.ipv4_address] : null + ipv6 = local.ipv6_address != null ? [local.ipv6_address] : null + protocol = "TCP" + ports = "22" + } + } +} + +resource "linode_instance" "foobar" { + provider = linode-producer + label = "{{ .InstanceLabel }}" + group = "tf_test" + type = "g6-standard-1" + region = "{{ .InstanceRegion }}" + + disk { + label = "disk" + size = 1000 + filesystem = "ext4" + } + + firewall_id = linode_firewall.e2e_test_firewall.id +} + +resource "linode_image" "image_one" { + provider = linode-producer + depends_on = [linode_instance.foobar] + linode_id = linode_instance.foobar.id + disk_id = linode_instance.foobar.disk.0.id + label = "{{ .ImageLabel1 }}" + description = "descriptive text image one" +} + +resource "linode_image" "image_two" { + provider = linode-producer + depends_on = [linode_instance.foobar] + linode_id = linode_instance.foobar.id + disk_id = linode_instance.foobar.disk.0.id + label = "{{ .ImageLabel2 }}" + description = "descriptive text image two" +} + +resource "linode_producer_image_share_group" "foobar" { + provider = linode-producer + label = "{{ .ShareGroupLabel }}" + description = "Example description" + images = [ + { + id = linode_image.image_one.id + description = "image one description" + label = "image_one_label" + }, + { + id = linode_image.image_two.id + description = "image two description" + label = "image_two_label" + } + ] +} + +resource "linode_consumer_image_share_group_token" "foobar" { + provider = linode-consumer + depends_on = [linode_producer_image_share_group.foobar] + valid_for_sharegroup_uuid = linode_producer_image_share_group.foobar.uuid + label = "{{ .TokenLabel }}" +} + +resource "linode_producer_image_share_group_member" "foobar" { + provider = linode-producer + depends_on = [linode_producer_image_share_group.foobar, linode_consumer_image_share_group_token.foobar] + sharegroup_id = linode_producer_image_share_group.foobar.id + token = linode_consumer_image_share_group_token.foobar.token + label = "{{ .MemberLabel }}" +} + +data "linode_consumer_image_share_group_image_shares" "all" { + provider = linode-consumer + depends_on = [linode_producer_image_share_group_member.foobar] + token_uuid = linode_consumer_image_share_group_token.foobar.token_uuid +} + +data "linode_consumer_image_share_group_image_shares" "by_label" { + provider = linode-consumer + depends_on = [linode_producer_image_share_group_member.foobar] + token_uuid = linode_consumer_image_share_group_token.foobar.token_uuid + + filter { + name = "label" + values = ["image_two_label"] + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/consumerimagesharegroupimageshares/tmpl/template.go b/linode/consumerimagesharegroupimageshares/tmpl/template.go new file mode 100644 index 000000000..dd43416a9 --- /dev/null +++ b/linode/consumerimagesharegroupimageshares/tmpl/template.go @@ -0,0 +1,32 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + FirewallLabel string + InstanceLabel string + InstanceRegion string + ImageLabel1 string + ImageLabel2 string + ShareGroupLabel string + TokenLabel string + MemberLabel string +} + +func DataBasic(t testing.TB, fwLabel, instanceLabel, instanceRegion, imageLabel1, imageLabel2, shareGroupLabel, tokenLabel, memberLabel string) string { + return acceptance.ExecuteTemplate(t, + "consumer_image_share_group_image_shares_data_basic", TemplateData{ + FirewallLabel: fwLabel, + InstanceLabel: instanceLabel, + InstanceRegion: instanceRegion, + ImageLabel1: imageLabel1, + ImageLabel2: imageLabel2, + ShareGroupLabel: shareGroupLabel, + TokenLabel: tokenLabel, + MemberLabel: memberLabel, + }) +} diff --git a/linode/framework_provider.go b/linode/framework_provider.go index be53569b8..59ded92fe 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -17,6 +17,7 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/childaccount" "github.com/linode/terraform-provider-linode/v3/linode/childaccounts" "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegroup" + "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegroupimageshares" "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptoken" "github.com/linode/terraform-provider-linode/v3/linode/consumerimagesharegrouptokens" "github.com/linode/terraform-provider-linode/v3/linode/databasebackups" @@ -351,5 +352,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource consumerimagesharegrouptoken.NewDataSource, consumerimagesharegrouptokens.NewDataSource, consumerimagesharegroup.NewDataSource, + consumerimagesharegroupimageshares.NewDataSource, } } diff --git a/linode/producerimagesharegroupimageshares/framework_schema_datasource.go b/linode/producerimagesharegroupimageshares/framework_schema_datasource.go index 6c0467d14..30ad9ff65 100644 --- a/linode/producerimagesharegroupimageshares/framework_schema_datasource.go +++ b/linode/producerimagesharegroupimageshares/framework_schema_datasource.go @@ -8,7 +8,7 @@ import ( var filterConfig = frameworkfilter.Config{ "id": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeString}, - "label": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeString}, + "label": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, } var Attributes = map[string]schema.Attribute{ From d5ed6d24447173782cc3afe412531c03dd57bc13 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Mon, 3 Nov 2025 10:28:11 -0500 Subject: [PATCH 21/29] Point to more recent linodego commit --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 1b340b61e..2e643c842 100644 --- a/go.mod +++ b/go.mod @@ -30,12 +30,12 @@ require ( github.com/linode/linodego v1.60.0 github.com/linode/linodego/k8s v1.25.2 github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.42.0 - golang.org/x/net v0.44.0 + golang.org/x/crypto v0.43.0 + golang.org/x/net v0.46.0 golang.org/x/sync v0.17.0 ) -replace github.com/linode/linodego => github.com/ezilber-akamai/linodego v0.0.0-20251006201737-d25cd1461fd9 +replace github.com/linode/linodego => github.com/linode/linodego v0.0.0-20251103144121-bc65eebf5d9a require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect @@ -103,13 +103,13 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/zclconf/go-cty v1.16.3 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/oauth2 v0.31.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.6.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/tools v0.37.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect google.golang.org/grpc v1.75.1 // indirect diff --git a/go.sum b/go.sum index ed1d12664..763ab2225 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,6 @@ github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhF github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/ezilber-akamai/linodego v0.0.0-20251006201737-d25cd1461fd9 h1:E1JfN9gJ9UROVsEEGKiN8ZmMSi8QbuQLoZYJztw8Cj8= -github.com/ezilber-akamai/linodego v0.0.0-20251006201737-d25cd1461fd9/go.mod h1:1+Bt0oTz5rBnDOJbGhccxn7LYVytXTIIfAy7QYmijDs= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= @@ -197,6 +195,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/linode/linodego v0.0.0-20251103144121-bc65eebf5d9a h1:Qrr5xWVUnAsXa0qJldsF+dkPInngvTlR70+bMFUJx2U= +github.com/linode/linodego v0.0.0-20251103144121-bc65eebf5d9a/go.mod h1:64o30geLNwR0NeYh5HM/WrVCBXcSqkKnRK3x9xoRuJI= github.com/linode/linodego/k8s v1.25.2 h1:PY6S0sAD3xANVvM9WY38bz9GqMTjIbytC8IJJ9Cv23o= github.com/linode/linodego/k8s v1.25.2/go.mod h1:DC1XCSRZRGsmaa/ggpDPSDUmOM6aK1bhSIP6+f9Cwhc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -288,23 +288,23 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= -golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -326,18 +326,18 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -345,8 +345,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 8ffc85fe8bbb1e9f8e2da8169477a62406946bf2 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Mon, 3 Nov 2025 13:57:51 -0500 Subject: [PATCH 22/29] Fixes --- ...consumer_image_share_group_image_shares.md | 20 +++++++++---------- .../producer_image_share_group.md | 1 - ...producer_image_share_group_image_shares.md | 16 +++++++-------- linode/framework_provider.go | 3 +++ .../framework_models.go | 10 +++------- .../framework_resource.go | 12 +---------- .../framework_schema_resource.go | 6 ++++++ .../producerimagesharegroup/tmpl/updates.gotf | 5 +++++ 8 files changed, 36 insertions(+), 37 deletions(-) diff --git a/docs/data-sources/consumer_image_share_group_image_shares.md b/docs/data-sources/consumer_image_share_group_image_shares.md index 08230f5df..996e232bc 100644 --- a/docs/data-sources/consumer_image_share_group_image_shares.md +++ b/docs/data-sources/consumer_image_share_group_image_shares.md @@ -6,8 +6,8 @@ description: |- # Data Source: linode\_consumer\_image\_share\_group\_image\_shares -Provides information about a list of Images that match a set of filters that have been -.shared in the Image Share Group that the provided Token has been accepted into +Provides information about a list of Images that match a set of filters that have been +.shared in the Image Share Group that the provided Token has been accepted into For more information, see the [Linode APIv4 docs](TODO). ## Example Usage @@ -69,14 +69,14 @@ Each Image Share will be stored in the `images_shares` attribute and will export * `is_public` - True if the Image is public. * `image_sharing` - Details about image sharing, including who the image is shared with and by. - * `shared_with` - Details about who the image is shared with. - * `sharegroup_count` - The number of sharegroups the private image is present in. - * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. - * `shared_by` - Details about who the image is shared by. - * `sharegroup_id` - The sharegroup_id from the im_ImageShare row. - * `sharegroup_uuid` - The sharegroup_uuid from the im_ImageShare row. - * `sharegroup_label` - The label from the associated im_ImageShareGroup row. - * `source_image_id` - The image id of the base image (will only be shown to producers, will be null for consumers). + * `shared_with` - Details about who the image is shared with. + * `sharegroup_count` - The number of sharegroups the private image is present in. + * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. + * `shared_by` - Details about who the image is shared by. + * `sharegroup_id` - The sharegroup_id from the im_ImageShare row. + * `sharegroup_uuid` - The sharegroup_uuid from the im_ImageShare row. + * `sharegroup_label` - The label from the associated im_ImageShareGroup row. + * `source_image_id` - The image id of the base image (will only be shown to producers, will be null for consumers). * `size` - The minimum size this Image needs to deploy. Size is in MB. example: 2500 diff --git a/docs/data-sources/producer_image_share_group.md b/docs/data-sources/producer_image_share_group.md index 64df2f531..d64c1b9f8 100644 --- a/docs/data-sources/producer_image_share_group.md +++ b/docs/data-sources/producer_image_share_group.md @@ -9,7 +9,6 @@ description: |- `linode_producer_image_share_group` provides details about an Image Share Group. For more information, see the [Linode APIv4 docs](TODO). - ## Example Usage The following example shows how the datasource might be used to obtain additional information about an Image Share Group. diff --git a/docs/data-sources/producer_image_share_group_image_shares.md b/docs/data-sources/producer_image_share_group_image_shares.md index 12c019085..509d26523 100644 --- a/docs/data-sources/producer_image_share_group_image_shares.md +++ b/docs/data-sources/producer_image_share_group_image_shares.md @@ -70,14 +70,14 @@ Each Image Share will be stored in the `images_shares` attribute and will export * `is_public` - True if the Image is public. * `image_sharing` - Details about image sharing, including who the image is shared with and by. - * `shared_with` - Details about who the image is shared with. - * `sharegroup_count` - The number of sharegroups the private image is present in. - * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. - * `shared_by` - Details about who the image is shared by. - * `sharegroup_id` - The sharegroup_id from the im_ImageShare row. - * `sharegroup_uuid` - The sharegroup_uuid from the im_ImageShare row. - * `sharegroup_label` - The label from the associated im_ImageShareGroup row. - * `source_image_id` - The image id of the base image (will only be shown to producers, will be null for consumers). + * `shared_with` - Details about who the image is shared with. + * `sharegroup_count` - The number of sharegroups the private image is present in. + * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. + * `shared_by` - Details about who the image is shared by. + * `sharegroup_id` - The sharegroup_id from the im_ImageShare row. + * `sharegroup_uuid` - The sharegroup_uuid from the im_ImageShare row. + * `sharegroup_label` - The label from the associated im_ImageShareGroup row. + * `source_image_id` - The image id of the base image (will only be shown to producers, will be null for consumers). * `size` - The minimum size this Image needs to deploy. Size is in MB. example: 2500 diff --git a/linode/framework_provider.go b/linode/framework_provider.go index 45ab3f06f..77f2080c6 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -264,6 +264,9 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res networkingipassignment.NewResource, obj.NewResource, databasemysqlv2.NewResource, + producerimagesharegroup.NewResource, + producerimagesharegroupmember.NewResource, + consumerimagesharegrouptoken.NewResource, } } diff --git a/linode/producerimagesharegroup/framework_models.go b/linode/producerimagesharegroup/framework_models.go index 2aa08bcf5..3d45723be 100644 --- a/linode/producerimagesharegroup/framework_models.go +++ b/linode/producerimagesharegroup/framework_models.go @@ -2,7 +2,6 @@ package producerimagesharegroup import ( "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/linode/linodego" @@ -52,12 +51,9 @@ func (data *ResourceModel) FlattenImageShareGroup( data.Expiry, timetypes.NewRFC3339TimePointerValue(imageShareGroup.Expiry), preserveKnown, ) - // Images must persist in state across CRUD operations but is not returned by the API. It will be maintained - // manually as a part of Create, Update, and Read, so we only need to set it here if it is null or unknown - // so that it is properly typed. - if data.Images.IsNull() || data.Images.IsUnknown() { - data.Images = types.ListValueMust(imageShareGroupImage.Type(), []attr.Value{}) - } + // Images will persist in state across CRUD operations even though it is not returned by the API. It is maintained + // manually as a part of Create, Update, and Read. We do not need to set it here because it defaults to a + // properly typed empty list when omitted from the config. } type DataSourceModel struct { diff --git a/linode/producerimagesharegroup/framework_resource.go b/linode/producerimagesharegroup/framework_resource.go index 75ef3c945..b9dc4fe6c 100644 --- a/linode/producerimagesharegroup/framework_resource.go +++ b/linode/producerimagesharegroup/framework_resource.go @@ -197,16 +197,6 @@ func (r *Resource) Update( return } - // Refresh remote state to ensure accuracy - sg, err := client.GetImageShareGroup(ctx, imageShareGroupID) - if err != nil { - resp.Diagnostics.AddError( - "Failed to read Image Share Group.", - err.Error(), - ) - return - } - imagesResp, err := client.ImageShareGroupListImageShareEntries(ctx, imageShareGroupID, nil) if err != nil { resp.Diagnostics.AddError( @@ -339,7 +329,7 @@ func (r *Resource) Update( } // Refresh and persist final state - sg, err = client.GetImageShareGroup(ctx, imageShareGroupID) + sg, err := client.GetImageShareGroup(ctx, imageShareGroupID) if err != nil { resp.Diagnostics.AddError("Failed to re-fetch share group", err.Error()) return diff --git a/linode/producerimagesharegroup/framework_schema_resource.go b/linode/producerimagesharegroup/framework_schema_resource.go index bb696d8da..e46af719e 100644 --- a/linode/producerimagesharegroup/framework_schema_resource.go +++ b/linode/producerimagesharegroup/framework_schema_resource.go @@ -2,11 +2,14 @@ package producerimagesharegroup import ( "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" ) var imageShareGroupImage = schema.NestedAttributeObject{ @@ -85,6 +88,9 @@ var frameworkResourceSchema = schema.Schema{ PlanModifiers: []planmodifier.List{ listplanmodifier.UseStateForUnknown(), }, + // Default to an empty list to ensure proper typing in state when not specified in config. This makes + // it so that the user specifying an empty list and not specifying the attribute at all is equivalent. + Default: listdefault.StaticValue(types.ListValueMust(imageShareGroupImage.Type(), []attr.Value{})), }, }, } diff --git a/linode/producerimagesharegroup/tmpl/updates.gotf b/linode/producerimagesharegroup/tmpl/updates.gotf index 3a2ee3b36..3ccb9e22e 100644 --- a/linode/producerimagesharegroup/tmpl/updates.gotf +++ b/linode/producerimagesharegroup/tmpl/updates.gotf @@ -35,6 +35,11 @@ resource "linode_producer_image_share_group" "foobar" { label = "{{ .ImageShareGroupLabel }}" description = "{{ .ImageShareGroupDescription }}" + depends_on = [ + linode_image.foobar, + linode_image.barfoo, + ] + {{- if .Images }} images = [ {{- range .Images }} From 474dab19ff649e3bda547e408405e2170fafaaf1 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Tue, 4 Nov 2025 11:32:34 -0500 Subject: [PATCH 23/29] Added LA notices to docs --- docs/data-sources/consumer_image_share_group.md | 2 +- docs/data-sources/consumer_image_share_group_image_shares.md | 4 ++-- docs/data-sources/consumer_image_share_group_token.md | 2 +- docs/data-sources/consumer_image_share_group_tokens.md | 2 +- docs/data-sources/image.md | 4 ++-- docs/data-sources/images.md | 4 ++-- docs/data-sources/producer_image_share_group.md | 2 +- docs/data-sources/producer_image_share_group_image_shares.md | 2 +- docs/data-sources/producer_image_share_group_member.md | 2 +- docs/data-sources/producer_image_share_group_members.md | 2 +- docs/data-sources/producer_image_share_groups.md | 2 +- docs/resources/consumer_image_share_group_token.md | 2 +- docs/resources/image.md | 4 ++-- docs/resources/producer_image_share_group.md | 2 +- docs/resources/producer_image_share_group_member.md | 2 +- 15 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/data-sources/consumer_image_share_group.md b/docs/data-sources/consumer_image_share_group.md index 2e00dfaec..050b7e1d0 100644 --- a/docs/data-sources/consumer_image_share_group.md +++ b/docs/data-sources/consumer_image_share_group.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_consumer\_image\_share\_group `linode_consumer_image_share_group` provides details about an Image Share Group that the user's token has been accepted into. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/consumer_image_share_group_image_shares.md b/docs/data-sources/consumer_image_share_group_image_shares.md index 996e232bc..5a0bc702f 100644 --- a/docs/data-sources/consumer_image_share_group_image_shares.md +++ b/docs/data-sources/consumer_image_share_group_image_shares.md @@ -7,8 +7,8 @@ description: |- # Data Source: linode\_consumer\_image\_share\_group\_image\_shares Provides information about a list of Images that match a set of filters that have been -.shared in the Image Share Group that the provided Token has been accepted into -For more information, see the [Linode APIv4 docs](TODO). +shared in the Image Share Group that the provided Token has been accepted into. +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/consumer_image_share_group_token.md b/docs/data-sources/consumer_image_share_group_token.md index 6c62fa5b5..40a40ca05 100644 --- a/docs/data-sources/consumer_image_share_group_token.md +++ b/docs/data-sources/consumer_image_share_group_token.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_consumer\_image\_share\_group\_token `linode_consumer_image_share_group_token` provides details about a Token for an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/consumer_image_share_group_tokens.md b/docs/data-sources/consumer_image_share_group_tokens.md index 2438f3424..11795a7ef 100644 --- a/docs/data-sources/consumer_image_share_group_tokens.md +++ b/docs/data-sources/consumer_image_share_group_tokens.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_consumer\_image\_share\_group\_tokens Provides information about a list of Image Share Group Tokens that match a set of filters. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/image.md b/docs/data-sources/image.md index 788986b71..22ab1821f 100644 --- a/docs/data-sources/image.md +++ b/docs/data-sources/image.md @@ -41,9 +41,9 @@ The Linode Image resource exports the following attributes: * `is_public` - True if the Image is public. -* `is_shared` - True if the Image is shared. +* `is_shared` - True if the Image is shared. (**Note: v4beta only and may not currently be available to all users.**) -* `image_sharing` - Details about image sharing, including who the image is shared with and by. +* `image_sharing` - Details about image sharing, including who the image is shared with and by. (**Note: v4beta only and may not currently be available to all users.**) * `shared_with` - Details about who the image is shared with. * `sharegroup_count` - The number of sharegroups the private image is present in. * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. diff --git a/docs/data-sources/images.md b/docs/data-sources/images.md index 9f5433b70..1c828eec5 100644 --- a/docs/data-sources/images.md +++ b/docs/data-sources/images.md @@ -79,9 +79,9 @@ Each Linode image will be stored in the `images` attribute and will export the f * `is_public` - True if the Image is public. -* `is_shared` - True if the Image is shared. +* `is_shared` - True if the Image is shared. (**Note: v4beta only and may not currently be available to all users.**) -* `image_sharing` - Details about image sharing, including who the image is shared with and by. +* `image_sharing` - Details about image sharing, including who the image is shared with and by. (**Note: v4beta only and may not currently be available to all users.**) * `shared_with` - Details about who the image is shared with. * `sharegroup_count` - The number of sharegroups the private image is present in. * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. diff --git a/docs/data-sources/producer_image_share_group.md b/docs/data-sources/producer_image_share_group.md index d64c1b9f8..21de3bdc7 100644 --- a/docs/data-sources/producer_image_share_group.md +++ b/docs/data-sources/producer_image_share_group.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_group `linode_producer_image_share_group` provides details about an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/producer_image_share_group_image_shares.md b/docs/data-sources/producer_image_share_group_image_shares.md index 509d26523..71d4a806a 100644 --- a/docs/data-sources/producer_image_share_group_image_shares.md +++ b/docs/data-sources/producer_image_share_group_image_shares.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_group\_image\_shares Provides information about a list of Images shared in the specified Image Share Group that match a set of filters. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/producer_image_share_group_member.md b/docs/data-sources/producer_image_share_group_member.md index 3cbea5932..d1bbc2069 100644 --- a/docs/data-sources/producer_image_share_group_member.md +++ b/docs/data-sources/producer_image_share_group_member.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_group\_member `linode_producer_image_share_group_member` provides details about a Member of an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/producer_image_share_group_members.md b/docs/data-sources/producer_image_share_group_members.md index c2af8aac6..0a87efc44 100644 --- a/docs/data-sources/producer_image_share_group_members.md +++ b/docs/data-sources/producer_image_share_group_members.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_group\_members Provides information about a list of Members of an Image Share Group that match a set of filters. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/producer_image_share_groups.md b/docs/data-sources/producer_image_share_groups.md index 1fa12bfd5..f1810d712 100644 --- a/docs/data-sources/producer_image_share_groups.md +++ b/docs/data-sources/producer_image_share_groups.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_groups Provides information about a list of Image Share Groups that match a set of filters. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/resources/consumer_image_share_group_token.md b/docs/resources/consumer_image_share_group_token.md index 2839bac29..07749c409 100644 --- a/docs/resources/consumer_image_share_group_token.md +++ b/docs/resources/consumer_image_share_group_token.md @@ -7,7 +7,7 @@ description: |- # linode\_consumer\_image\_share\_group\_token Manages a token for an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/resources/image.md b/docs/resources/image.md index 01d2f2ca0..2f63909e2 100644 --- a/docs/resources/image.md +++ b/docs/resources/image.md @@ -121,9 +121,9 @@ This resource exports the following attributes: * `is_public` - True if the Image is public. -* `is_shared` - True if the Image is shared. +* `is_shared` - True if the Image is shared. (**Note: v4beta only and may not currently be available to all users.**) -* `image_sharing` - Details about image sharing, including who the image is shared with and by. +* `image_sharing` - Details about image sharing, including who the image is shared with and by. (**Note: v4beta only and may not currently be available to all users.**) * `shared_with` - Details about who the image is shared with. * `sharegroup_count` - The number of sharegroups the private image is present in. * `sharegroup_list_url` - The GET api url to view the sharegroups in which the image is shared. diff --git a/docs/resources/producer_image_share_group.md b/docs/resources/producer_image_share_group.md index 286253298..7878e08ff 100644 --- a/docs/resources/producer_image_share_group.md +++ b/docs/resources/producer_image_share_group.md @@ -7,7 +7,7 @@ description: |- # linode\_producer\_image\_share\_group Manages an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/resources/producer_image_share_group_member.md b/docs/resources/producer_image_share_group_member.md index ced4174e0..c3485aaa0 100644 --- a/docs/resources/producer_image_share_group_member.md +++ b/docs/resources/producer_image_share_group_member.md @@ -7,7 +7,7 @@ description: |- # linode\_producer\_image\_share\_group\_member Manages a member of an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). +For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. ## Example Usage From 80d6cf8f28777b2c817b892fb41bd3354d5d19e0 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Wed, 5 Nov 2025 10:29:28 -0500 Subject: [PATCH 24/29] Added documentation links --- docs/data-sources/consumer_image_share_group.md | 2 +- docs/data-sources/consumer_image_share_group_image_shares.md | 2 +- docs/data-sources/consumer_image_share_group_token.md | 2 +- docs/data-sources/consumer_image_share_group_tokens.md | 2 +- docs/data-sources/producer_image_share_group.md | 2 +- docs/data-sources/producer_image_share_group_image_shares.md | 2 +- docs/data-sources/producer_image_share_group_member.md | 2 +- docs/data-sources/producer_image_share_group_members.md | 2 +- docs/data-sources/producer_image_share_groups.md | 2 +- docs/resources/consumer_image_share_group_token.md | 2 +- docs/resources/producer_image_share_group.md | 2 +- docs/resources/producer_image_share_group_member.md | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/data-sources/consumer_image_share_group.md b/docs/data-sources/consumer_image_share_group.md index 050b7e1d0..869a0cd5a 100644 --- a/docs/data-sources/consumer_image_share_group.md +++ b/docs/data-sources/consumer_image_share_group.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_consumer\_image\_share\_group `linode_consumer_image_share_group` provides details about an Image Share Group that the user's token has been accepted into. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/get-sharegroup-by-token). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/consumer_image_share_group_image_shares.md b/docs/data-sources/consumer_image_share_group_image_shares.md index 5a0bc702f..e8104d606 100644 --- a/docs/data-sources/consumer_image_share_group_image_shares.md +++ b/docs/data-sources/consumer_image_share_group_image_shares.md @@ -8,7 +8,7 @@ description: |- Provides information about a list of Images that match a set of filters that have been shared in the Image Share Group that the provided Token has been accepted into. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/get-sharegroup-images-by-token). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/consumer_image_share_group_token.md b/docs/data-sources/consumer_image_share_group_token.md index 40a40ca05..d1f1f788b 100644 --- a/docs/data-sources/consumer_image_share_group_token.md +++ b/docs/data-sources/consumer_image_share_group_token.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_consumer\_image\_share\_group\_token `linode_consumer_image_share_group_token` provides details about a Token for an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/get-sharegroup-token). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/consumer_image_share_group_tokens.md b/docs/data-sources/consumer_image_share_group_tokens.md index 11795a7ef..71dd15671 100644 --- a/docs/data-sources/consumer_image_share_group_tokens.md +++ b/docs/data-sources/consumer_image_share_group_tokens.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_consumer\_image\_share\_group\_tokens Provides information about a list of Image Share Group Tokens that match a set of filters. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/get-user-tokens). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/producer_image_share_group.md b/docs/data-sources/producer_image_share_group.md index 21de3bdc7..b186510f6 100644 --- a/docs/data-sources/producer_image_share_group.md +++ b/docs/data-sources/producer_image_share_group.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_group `linode_producer_image_share_group` provides details about an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/get-sharegroup). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/producer_image_share_group_image_shares.md b/docs/data-sources/producer_image_share_group_image_shares.md index 71d4a806a..c40ebeab6 100644 --- a/docs/data-sources/producer_image_share_group_image_shares.md +++ b/docs/data-sources/producer_image_share_group_image_shares.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_group\_image\_shares Provides information about a list of Images shared in the specified Image Share Group that match a set of filters. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/get-sharegroup-images). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/producer_image_share_group_member.md b/docs/data-sources/producer_image_share_group_member.md index d1bbc2069..85f6faac4 100644 --- a/docs/data-sources/producer_image_share_group_member.md +++ b/docs/data-sources/producer_image_share_group_member.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_group\_member `linode_producer_image_share_group_member` provides details about a Member of an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/get-sharegroup-member-token). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/producer_image_share_group_members.md b/docs/data-sources/producer_image_share_group_members.md index 0a87efc44..9747983e1 100644 --- a/docs/data-sources/producer_image_share_group_members.md +++ b/docs/data-sources/producer_image_share_group_members.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_group\_members Provides information about a list of Members of an Image Share Group that match a set of filters. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/get-sharegroup-members). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/data-sources/producer_image_share_groups.md b/docs/data-sources/producer_image_share_groups.md index f1810d712..5c878b184 100644 --- a/docs/data-sources/producer_image_share_groups.md +++ b/docs/data-sources/producer_image_share_groups.md @@ -7,7 +7,7 @@ description: |- # Data Source: linode\_producer\_image\_share\_groups Provides information about a list of Image Share Groups that match a set of filters. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/get-sharegroups). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/resources/consumer_image_share_group_token.md b/docs/resources/consumer_image_share_group_token.md index 07749c409..819cee7a2 100644 --- a/docs/resources/consumer_image_share_group_token.md +++ b/docs/resources/consumer_image_share_group_token.md @@ -7,7 +7,7 @@ description: |- # linode\_consumer\_image\_share\_group\_token Manages a token for an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/post-sharegroup-tokens). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/resources/producer_image_share_group.md b/docs/resources/producer_image_share_group.md index 7878e08ff..39227fd2b 100644 --- a/docs/resources/producer_image_share_group.md +++ b/docs/resources/producer_image_share_group.md @@ -7,7 +7,7 @@ description: |- # linode\_producer\_image\_share\_group Manages an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/post-sharegroups). May not be currently available to all users even under v4beta. ## Example Usage diff --git a/docs/resources/producer_image_share_group_member.md b/docs/resources/producer_image_share_group_member.md index c3485aaa0..974f423da 100644 --- a/docs/resources/producer_image_share_group_member.md +++ b/docs/resources/producer_image_share_group_member.md @@ -7,7 +7,7 @@ description: |- # linode\_producer\_image\_share\_group\_member Manages a member of an Image Share Group. -For more information, see the [Linode APIv4 docs](TODO). May not be currently available to all users even under v4beta. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/post-sharegroup-members). May not be currently available to all users even under v4beta. ## Example Usage From 4c8b8934861bc0ff0f0074d1e31509f977429cc0 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Fri, 7 Nov 2025 13:13:37 -0500 Subject: [PATCH 25/29] Point at latest linodego release --- go.mod | 4 +--- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 2e643c842..534a48e02 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/hashicorp/terraform-plugin-mux v0.21.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 github.com/hashicorp/terraform-plugin-testing v1.13.3 - github.com/linode/linodego v1.60.0 + github.com/linode/linodego v1.61.0 github.com/linode/linodego/k8s v1.25.2 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.43.0 @@ -35,8 +35,6 @@ require ( golang.org/x/sync v0.17.0 ) -replace github.com/linode/linodego => github.com/linode/linodego v0.0.0-20251103144121-bc65eebf5d9a - require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/agext/levenshtein v1.2.2 // indirect diff --git a/go.sum b/go.sum index 763ab2225..b8cc1a592 100644 --- a/go.sum +++ b/go.sum @@ -195,8 +195,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/linode/linodego v0.0.0-20251103144121-bc65eebf5d9a h1:Qrr5xWVUnAsXa0qJldsF+dkPInngvTlR70+bMFUJx2U= -github.com/linode/linodego v0.0.0-20251103144121-bc65eebf5d9a/go.mod h1:64o30geLNwR0NeYh5HM/WrVCBXcSqkKnRK3x9xoRuJI= +github.com/linode/linodego v1.61.0 h1:9g20NWl+/SbhDFj6X5EOZXtM2hBm1Mx8I9h8+F3l1LM= +github.com/linode/linodego v1.61.0/go.mod h1:64o30geLNwR0NeYh5HM/WrVCBXcSqkKnRK3x9xoRuJI= github.com/linode/linodego/k8s v1.25.2 h1:PY6S0sAD3xANVvM9WY38bz9GqMTjIbytC8IJJ9Cv23o= github.com/linode/linodego/k8s v1.25.2/go.mod h1:DC1XCSRZRGsmaa/ggpDPSDUmOM6aK1bhSIP6+f9Cwhc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= From 307dc91bfaa388ea4e6d94a17cbb9db334ed94a9 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Wed, 12 Nov 2025 13:22:36 -0500 Subject: [PATCH 26/29] Added new tokens to integration_tests.yml --- .github/workflows/integration_tests.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index aa3602efd..b05b5747d 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -55,8 +55,10 @@ jobs: run: | case "${{ matrix.user }}" in "USER_1") - echo "TEST_SUITE=acceptance,backup,domain,domainrecord,domains,domainzonefile,helper,instance,provider" >> $GITHUB_ENV + echo "TEST_SUITE=acceptance,backup,consumerimagesharegroup,consumerimagesharegroupimageshares,consumerimagesharegrouptoken,consumerimagesharegrouptokens,producerimagesharegroup,producerimagesharegroupimageshares,producerimagesharegroupmember,producerimagesharegroupmembers,producerimagesharegroups,domain,domainrecord,domains,domainzonefile,helper,instance,provider" >> $GITHUB_ENV echo "LINODE_TOKEN=${{ secrets.LINODE_TOKEN_USER_1 }}" >> $GITHUB_ENV + echo "LINODE_PRODUCER_TOKEN=${{ secrets.LINODE_TOKEN_USER_1 }}" >> $GITHUB_ENV + echo "LINODE_CONSUMER_TOKEN=${{ secrets.LINODE_TOKEN_USER_2 }}" >> $GITHUB_ENV ;; "USER_2") echo "TEST_SUITE=databasemysqlv2,firewall,firewallsettings,firewalltemplate,firewalltemplates,firewalldevice,firewalls,image,images,instancenetworking,instancesharedips,instancetype,instancetypes,ipv6range,ipv6ranges,kernel,kernels,nb,nbconfig,nbconfigs,nbnode,nbs,nbvpc,nbvpcs,sshkey,sshkeys,vlan,volume,volumes,vpc,vpcs,vpcsubnets,vpcips" >> $GITHUB_ENV @@ -80,6 +82,8 @@ jobs: make TEST_SUITE="${{ env.TEST_SUITE }}" PARALLEL="${{ github.event.inputs.parallel_value || '5' }}" test-int | go-junit-report -set-exit-code -iocopy -out $REPORT_FILENAME env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} + LINODE_PRODUCER_TOKEN: ${{ env.LINODE_PRODUCER_TOKEN }} + LINODE_CONSUMER_TOKEN: ${{ env.LINODE_CONSUMER_TOKEN }} - name: Upload Test Report as Artifact if: always() From 5776cd1faabed8015a7f8a3db85af7bc7f29cb1a Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Wed, 12 Nov 2025 13:58:46 -0500 Subject: [PATCH 27/29] Added new tokens to integration_tests_pr.yml --- .github/workflows/integration_tests_pr.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration_tests_pr.yml b/.github/workflows/integration_tests_pr.yml index 77acfba7e..cce5421d9 100644 --- a/.github/workflows/integration_tests_pr.yml +++ b/.github/workflows/integration_tests_pr.yml @@ -63,8 +63,10 @@ jobs: if: ${{ steps.disallowed-character-check.outputs.result == 'pass' }} env: LINODE_TOKEN: ${{ secrets.DX_LINODE_TOKEN }} + LINODE_PRODUCER_TOKEN: ${{ secrets.DX_LINODE_TOKEN }} + LINODE_CONSUMER_TOKEN: ${{ secrets.LINODE_TOKEN_USER_2 }} RUN_LONG_TESTS: ${{ inputs.run_long_tests }} - + - name: Get the hash value of the latest commit from the PR branch uses: octokit/graphql-action@v2.x id: commit-hash From ea64be25efbce080bd7298d42b02f0e714bb62a3 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 13 Nov 2025 15:52:34 -0500 Subject: [PATCH 28/29] Fix test --- linode/consumerimagesharegroupimageshares/tmpl/data_basic.gotf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/linode/consumerimagesharegroupimageshares/tmpl/data_basic.gotf b/linode/consumerimagesharegroupimageshares/tmpl/data_basic.gotf index f6f078567..0e777555a 100644 --- a/linode/consumerimagesharegroupimageshares/tmpl/data_basic.gotf +++ b/linode/consumerimagesharegroupimageshares/tmpl/data_basic.gotf @@ -48,6 +48,8 @@ resource "linode_firewall" "e2e_test_firewall" { resource "linode_instance" "foobar" { provider = linode-producer + depends_on = [linode_firewall.e2e_test_firewall] + label = "{{ .InstanceLabel }}" group = "tf_test" type = "g6-standard-1" From fb0a874f66803db62cad4550eab977598c720073 Mon Sep 17 00:00:00 2001 From: ezilber-akamai Date: Thu, 13 Nov 2025 16:30:11 -0500 Subject: [PATCH 29/29] Addressed PR comments --- linode/consumerimagesharegrouptoken/framework_resource.go | 3 --- linode/helper/framework_data_test.go | 1 - 2 files changed, 4 deletions(-) diff --git a/linode/consumerimagesharegrouptoken/framework_resource.go b/linode/consumerimagesharegrouptoken/framework_resource.go index 5fdb1afbb..2513a9ab2 100644 --- a/linode/consumerimagesharegrouptoken/framework_resource.go +++ b/linode/consumerimagesharegrouptoken/framework_resource.go @@ -138,9 +138,6 @@ func (r *Resource) Update( } plan.FlattenImageShareGroupToken(token, false) - if resp.Diagnostics.HasError() { - return - } } plan.CopyFrom(state, true) diff --git a/linode/helper/framework_data_test.go b/linode/helper/framework_data_test.go index 335b82eae..d951e9e43 100644 --- a/linode/helper/framework_data_test.go +++ b/linode/helper/framework_data_test.go @@ -2,7 +2,6 @@ package helper_test import ( "context" - //"fmt" "testing" "github.com/hashicorp/terraform-plugin-framework/attr"