Skip to content

Commit f7a6581

Browse files
authored
Merge pull request #13 from platform9/feat/images-properties
feat(images): settable custom image properties
2 parents f0c3c53 + 0ef3601 commit f7a6581

4 files changed

Lines changed: 73 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ All notable changes to this project are documented here. The format is based on
2626
`pcd_identity_user`, `pcd_identity_role_assignment`, `pcd_identity_application_credential`.
2727
- Identity data sources: `pcd_identity_project`, `pcd_identity_user`, `pcd_identity_role`.
2828
- Images (Glance v2): `pcd_images_image` resource (local-file upload + web-download import,
29-
status waiter, checksum verify, unprotect-before-delete) and `pcd_images_image` /
30-
`pcd_images_image_ids` data sources.
29+
status waiter, checksum verify, unprotect-before-delete, settable custom `properties`
30+
metadata) and `pcd_images_image` / `pcd_images_image_ids` data sources.
3131
- Networking (Neutron v2): resources `pcd_networking_network`, `_subnet`, `_secgroup`,
3232
`_secgroup_rule`, `_router`, `_router_interface`; data sources `pcd_networking_network`,
3333
`_subnet`, `_secgroup`.

DECISIONS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ yet passable on this lab (reason noted). Generated registry docs are not committ
1717
| Identity (DS) | `pcd_identity_project`, `_user`, `_role` | **VALIDATED** |
1818
| Images | `pcd_images_image` (local-file upload); web-download import path also exercised | **VALIDATED** |
1919
| Images (DS) | `pcd_images_image`, `_image_ids` | **VALIDATED** |
20+
| Images | `pcd_images_image` settable `properties` (custom metadata) | **PENDING** — code-complete; add/replace/remove via JSON-patch, echo-only Read filters Glance system properties to avoid perpetual diff. Acc test extended (create/update/import). Not yet run live (credentials unavailable this session). |
2021
| Networking | `pcd_networking_network`, `_subnet`, `_secgroup`, `_secgroup_rule`, `_router`, `_router_interface` | **VALIDATED** |
2122
| Networking (DS) | `pcd_networking_network`, `_subnet`, `_secgroup` | **VALIDATED** |
2223
| Networking | `pcd_networking_port` | **PENDING** — code-complete, build/vet/lint/docs clean; acceptance test written (create/update/import). Not yet run live: lab credentials were unavailable in this session. No lab-side blocker expected. |

internal/services/images/image_resource.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
2929
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
3030
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
31+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier"
3132
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
3233
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier"
3334
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
@@ -100,7 +101,7 @@ func (r *imageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp
100101
"hidden": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), MarkdownDescription: "Whether the image is hidden from the default list."},
101102
"tags": schema.SetAttribute{Optional: true, Computed: true, ElementType: types.StringType, MarkdownDescription: "Tags applied to the image.", PlanModifiers: []planmodifier.Set{setplanmodifier.UseStateForUnknown()}},
102103
"verify_checksum": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true), MarkdownDescription: "Verify the uploaded file's md5 against the Glance checksum (local_file_path only)."},
103-
"properties": schema.MapAttribute{Computed: true, ElementType: types.StringType, MarkdownDescription: "Image properties reported by Glance (read-only in this release).", PlanModifiers: []planmodifier.Map{}},
104+
"properties": schema.MapAttribute{Optional: true, Computed: true, ElementType: types.StringType, MarkdownDescription: "User-defined key/value image properties (custom Glance metadata). Only keys you set here are managed; Glance system/read-only properties are not tracked.", PlanModifiers: []planmodifier.Map{mapplanmodifier.UseStateForUnknown()}},
104105
"checksum": schema.StringAttribute{Computed: true, MarkdownDescription: "md5 checksum of the image data."},
105106
"size_bytes": schema.Int64Attribute{Computed: true, MarkdownDescription: "Size of the image data in bytes."},
106107
"status": schema.StringAttribute{Computed: true, MarkdownDescription: "Glance status (e.g. active)."},
@@ -170,6 +171,14 @@ func (r *imageResource) Create(ctx context.Context, req resource.CreateRequest,
170171
vis := images.ImageVisibility(v)
171172
createOpts.Visibility = &vis
172173
}
174+
if !plan.Properties.IsNull() && !plan.Properties.IsUnknown() {
175+
var userProps map[string]string
176+
resp.Diagnostics.Append(plan.Properties.ElementsAs(ctx, &userProps, false)...)
177+
if resp.Diagnostics.HasError() {
178+
return
179+
}
180+
createOpts.Properties = userProps
181+
}
173182

174183
img, err := images.Create(ctx, client, createOpts).Extract()
175184
if err != nil {
@@ -274,6 +283,30 @@ func (r *imageResource) Update(ctx context.Context, req resource.UpdateRequest,
274283
}
275284
patch = append(patch, images.ReplaceImageTags{NewTags: tags})
276285
}
286+
if !plan.Properties.Equal(state.Properties) {
287+
var planProps, stateProps map[string]string
288+
if !plan.Properties.IsNull() && !plan.Properties.IsUnknown() {
289+
resp.Diagnostics.Append(plan.Properties.ElementsAs(ctx, &planProps, false)...)
290+
}
291+
if !state.Properties.IsNull() && !state.Properties.IsUnknown() {
292+
resp.Diagnostics.Append(state.Properties.ElementsAs(ctx, &stateProps, false)...)
293+
}
294+
if resp.Diagnostics.HasError() {
295+
return
296+
}
297+
for k, v := range planProps {
298+
if sv, ok := stateProps[k]; !ok {
299+
patch = append(patch, images.UpdateImageProperty{Op: images.AddOp, Name: k, Value: v})
300+
} else if sv != v {
301+
patch = append(patch, images.UpdateImageProperty{Op: images.ReplaceOp, Name: k, Value: v})
302+
}
303+
}
304+
for k := range stateProps {
305+
if _, ok := planProps[k]; !ok {
306+
patch = append(patch, images.UpdateImageProperty{Op: images.RemoveOp, Name: k})
307+
}
308+
}
309+
}
277310

278311
if len(patch) > 0 {
279312
if _, err := images.Update(ctx, client, plan.ID.ValueString(), patch).Extract(); err != nil {
@@ -383,9 +416,23 @@ func (r *imageResource) flatten(ctx context.Context, img *images.Image, m *image
383416
diags = append(diags, d...)
384417
m.Tags = tags
385418

386-
props := make(map[string]string, len(img.Properties))
419+
// Echo-only: Glance returns many system/read-only properties (os_hash_*, stores,
420+
// direct_url, owner_specified.*, ...) via RemainingKeys. Track only the keys the
421+
// user manages (already present in the model), or every apply would try to
422+
// modify read-only props and the plan would never converge.
423+
managed := map[string]struct{}{}
424+
if !m.Properties.IsNull() && !m.Properties.IsUnknown() {
425+
var cur map[string]string
426+
diags = append(diags, m.Properties.ElementsAs(ctx, &cur, false)...)
427+
for k := range cur {
428+
managed[k] = struct{}{}
429+
}
430+
}
431+
props := make(map[string]string, len(managed))
387432
for k, v := range img.Properties {
388-
props[k] = fmt.Sprintf("%v", v)
433+
if _, ok := managed[k]; ok {
434+
props[k] = fmt.Sprintf("%v", v)
435+
}
389436
}
390437
propsMap, d := types.MapValueFrom(ctx, types.StringType, props)
391438
diags = append(diags, d...)

internal/services/images/image_resource_test.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,31 +29,42 @@ func TestAccImagesImageResource_basic(t *testing.T) {
2929
CheckDestroy: testAccCheckImageDestroy(t),
3030
Steps: []resource.TestStep{
3131
{
32-
Config: testAccImageConfig(imgFile, "tf-acc-image", 0, false),
32+
Config: testAccImageConfig(imgFile, "tf-acc-image", 0, false, `properties = { role = "web", env = "test" }`),
3333
Check: resource.ComposeAggregateTestCheckFunc(
3434
testAccCheckImageExists(t, resourceName),
3535
resource.TestCheckResourceAttr(resourceName, "name", "tf-acc-image"),
3636
resource.TestCheckResourceAttr(resourceName, "status", "active"),
3737
resource.TestCheckResourceAttr(resourceName, "disk_format", "raw"),
3838
resource.TestCheckResourceAttrSet(resourceName, "checksum"),
3939
resource.TestCheckResourceAttrSet(resourceName, "size_bytes"),
40+
// Only user-set keys are tracked (no os_hash_* system props leaking in).
41+
resource.TestCheckResourceAttr(resourceName, "properties.%", "2"),
42+
resource.TestCheckResourceAttr(resourceName, "properties.role", "web"),
43+
resource.TestCheckResourceAttr(resourceName, "properties.env", "test"),
4044
resource.TestCheckResourceAttrPair("data.pcd_images_image.by_name", "id", resourceName, "id"),
4145
resource.TestCheckResourceAttr("data.pcd_images_image_ids.by_name", "ids.#", "1"),
4246
),
4347
},
4448
{
45-
Config: testAccImageConfig(imgFile, "tf-acc-image-updated", 1, true),
49+
// Replace role, remove env, add tier.
50+
Config: testAccImageConfig(imgFile, "tf-acc-image-updated", 1, true, `properties = { role = "app", tier = "1" }`),
4651
Check: resource.ComposeAggregateTestCheckFunc(
4752
resource.TestCheckResourceAttr(resourceName, "name", "tf-acc-image-updated"),
4853
resource.TestCheckResourceAttr(resourceName, "min_disk_gb", "1"),
4954
resource.TestCheckResourceAttr(resourceName, "protected", "true"),
55+
resource.TestCheckResourceAttr(resourceName, "properties.%", "2"),
56+
resource.TestCheckResourceAttr(resourceName, "properties.role", "app"),
57+
resource.TestCheckResourceAttr(resourceName, "properties.tier", "1"),
58+
resource.TestCheckNoResourceAttr(resourceName, "properties.env"),
5059
),
5160
},
5261
{
53-
ResourceName: resourceName,
54-
ImportState: true,
55-
ImportStateVerify: true,
56-
ImportStateVerifyIgnore: []string{"local_file_path", "image_source_url", "verify_checksum"},
62+
ResourceName: resourceName,
63+
ImportState: true,
64+
ImportStateVerify: true,
65+
// properties is echo-only, so an imported resource starts with no managed
66+
// keys (state has no prior key set); the first apply re-adds them.
67+
ImportStateVerifyIgnore: []string{"local_file_path", "image_source_url", "verify_checksum", "properties"},
5768
},
5869
},
5970
})
@@ -72,7 +83,7 @@ func writeTempImage(t *testing.T) string {
7283
return p
7384
}
7485

75-
func testAccImageConfig(path, name string, minDiskGB int, protected bool) string {
86+
func testAccImageConfig(path, name string, minDiskGB int, protected bool, propsHCL string) string {
7687
return fmt.Sprintf(`
7788
resource "pcd_images_image" "test" {
7889
name = %q
@@ -81,6 +92,7 @@ resource "pcd_images_image" "test" {
8192
local_file_path = %q
8293
min_disk_gb = %d
8394
protected = %t
95+
%s
8496
}
8597
8698
data "pcd_images_image" "by_name" {
@@ -91,7 +103,7 @@ data "pcd_images_image" "by_name" {
91103
data "pcd_images_image_ids" "by_name" {
92104
name = pcd_images_image.test.name
93105
}
94-
`, name, path, minDiskGB, protected)
106+
`, name, path, minDiskGB, protected, propsHCL)
95107
}
96108

97109
func testAccCheckImageExists(t *testing.T, n string) resource.TestCheckFunc {

0 commit comments

Comments
 (0)