Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ All notable changes to this project are documented here. The format is based on
`pcd_compute_servergroup` (acceptance-tested); `pcd_compute_instance` (code-complete —
boot verification is blocked on a lab image-library issue, see DECISIONS.md); data
sources `pcd_compute_flavor`, `pcd_compute_keypair`, `pcd_compute_availability_zones`.
- Compute follow-ups: `pcd_compute_flavor` gains settable `extra_specs` (added/changed/
removed in place; the flavor's other attributes are now correctly immutable);
`pcd_compute_instance` supports in-place **resize** on a flavor change and booting by
`image_name` (resolved via Glance, alternative to `image_id`); new resources
`pcd_compute_interface_attach` (attach a port/network to a server) and
`pcd_compute_volume_attach` (attach a Cinder volume to a server).
- Block storage (Cinder v3): `pcd_blockstorage_volume` resource (create/extend/import;
code-complete — acceptance is blocked on the CE lab having no storage backend, see
DECISIONS.md) and `pcd_blockstorage_volume` / `pcd_blockstorage_snapshot` data sources.
Expand Down
4 changes: 4 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ yet passable on this lab (reason noted). Generated registry docs are not committ
| Compute | `pcd_compute_keypair`, `_flavor`, `_servergroup` | **VALIDATED** |
| Compute (DS) | `pcd_compute_flavor`, `_keypair`, `_availability_zones` | **VALIDATED** |
| Compute | `pcd_compute_instance` (boot) | **PENDING** — lab image-library gap: images don't reach the onboarded host's local library → nova returns HTTP 204 for image data. Create/schedule/wait/error-report verified; passes with a library-backed image. |
| Compute | `pcd_compute_flavor` `extra_specs` | **PENDING** — code-complete; in-place add/change/remove; other flavor attrs now correctly force replacement. Acc test written (create + in-place update + import). Not yet run live (credentials unavailable this session). No lab blocker expected — flavors work on the lab. |
| Compute | `pcd_compute_instance` resize + `image_name` | **PENDING** — code-complete; flavor change → Nova resize/confirm (revert on failure); `image_name` resolved via Glance. Boot-blocked (same as instance boot above). |
| Compute | `pcd_compute_interface_attach` | **PENDING** — code-complete; needs a booted instance (boot-blocked). Acc test written. |
| Compute | `pcd_compute_volume_attach` | **PENDING** — code-complete; needs a booted instance **and** a Cinder backend (both lab-blocked). Best-effort volume waiter degrades gracefully without Cinder. |
| Block storage | `pcd_blockstorage_volume` | **PENDING** — no Cinder storage backend on the lab (`storageBackends={}`); volumes go `creating → error`. Create + waiter + error-detection verified. |
| Block storage (DS) | `pcd_blockstorage_volume`, `_snapshot` | **PENDING** — untestable without volumes on this lab. |

Expand Down
2 changes: 2 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ func (p *pcdProvider) Resources(_ context.Context) []func() resource.Resource {
compute.NewInstanceResource,
compute.NewFlavorResource,
compute.NewServergroupResource,
compute.NewInterfaceAttachResource,
compute.NewVolumeAttachResource,
blockstorage.NewVolumeResource,
}
}
Expand Down
24 changes: 24 additions & 0 deletions internal/services/compute/compute.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,26 @@
package compute

import (
"context"
"fmt"
"strings"

"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types"

"github.com/platform9/terraform-provider-pcd/internal/clients"
)

// splitInstanceScopedID parses a composite resource/import ID of the form
// "<instance_id>/<sub_id>" (used by the attach resources).
func splitInstanceScopedID(id string) (instanceID, sub string, err error) {
parts := strings.SplitN(id, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("expected import ID in the form <instance_id>/<id>, got %q", id)
}
return parts[0], parts[1], nil
}

// configureClient extracts the shared *clients.Config from ProviderData.
func configureClient(providerData any, diags *diag.Diagnostics) *clients.Config {
if providerData == nil {
Expand All @@ -28,3 +41,14 @@ func configureClient(providerData any, diags *diag.Diagnostics) *clients.Config
}
return config
}

// extractStringMap converts a Terraform map value to a Go map[string]string,
// returning nil for a null/unknown map. Conversion diagnostics are appended.
func extractStringMap(ctx context.Context, m types.Map, diags *diag.Diagnostics) map[string]string {
if m.IsNull() || m.IsUnknown() {
return nil
}
out := map[string]string{}
diags.Append(m.ElementsAs(ctx, &out, false)...)
return out
}
101 changes: 88 additions & 13 deletions internal/services/compute/flavor_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ import (

"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"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"
"github.com/hashicorp/terraform-plugin-framework/types"
Expand Down Expand Up @@ -53,6 +56,7 @@ type flavorModel struct {
RxTxFactor types.Float64 `tfsdk:"rx_tx_factor"`
IsPublic types.Bool `tfsdk:"is_public"`
Ephemeral types.Int64 `tfsdk:"ephemeral"`
ExtraSpecs types.Map `tfsdk:"extra_specs"`
Region types.String `tfsdk:"region"`
}

Expand All @@ -63,20 +67,23 @@ func (r *flavorResource) Metadata(_ context.Context, req resource.MetadataReques
func (r *flavorResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
fn := []planmodifier.String{stringplanmodifier.RequiresReplace()}
fnC := []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()}
fnInt := []planmodifier.Int64{int64planmodifier.RequiresReplace()}
resp.Schema = schema.Schema{
MarkdownDescription: "Manages a compute flavor in PCD's Nova service (admin). Flavors are immutable; " +
"any change forces a new resource.",
MarkdownDescription: "Manages a compute flavor in PCD's Nova service (admin). Every attribute except " +
"`extra_specs` is immutable; changing one forces a new resource. `extra_specs` can be added, " +
"changed, or removed in place.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Computed: true, MarkdownDescription: "The flavor ID.", PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
"name": schema.StringAttribute{Required: true, MarkdownDescription: "The name of the flavor.", PlanModifiers: fn},
"ram": schema.Int64Attribute{Required: true, MarkdownDescription: "Memory in MB.", PlanModifiers: []planmodifier.Int64{}},
"vcpus": schema.Int64Attribute{Required: true, MarkdownDescription: "Number of vCPUs.", PlanModifiers: []planmodifier.Int64{}},
"disk": schema.Int64Attribute{Required: true, MarkdownDescription: "Root disk in GB.", PlanModifiers: []planmodifier.Int64{}},
"flavor_id": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The desired flavor ID (auto-generated if omitted).", PlanModifiers: fnC},
"swap": schema.Int64Attribute{Optional: true, Computed: true, Default: int64default.StaticInt64(0), MarkdownDescription: "Swap space in MB."},
"rx_tx_factor": schema.Float64Attribute{Optional: true, Computed: true, MarkdownDescription: "RX/TX factor.", PlanModifiers: []planmodifier.Float64{float64planmodifier.UseStateForUnknown()}},
"is_public": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true), MarkdownDescription: "Whether the flavor is public."},
"ephemeral": schema.Int64Attribute{Optional: true, Computed: true, Default: int64default.StaticInt64(0), MarkdownDescription: "Ephemeral disk in GB."},
"name": schema.StringAttribute{Required: true, MarkdownDescription: "The name of the flavor. Changing this forces a new resource.", PlanModifiers: fn},
"ram": schema.Int64Attribute{Required: true, MarkdownDescription: "Memory in MB. Changing this forces a new resource.", PlanModifiers: fnInt},
"vcpus": schema.Int64Attribute{Required: true, MarkdownDescription: "Number of vCPUs. Changing this forces a new resource.", PlanModifiers: fnInt},
"disk": schema.Int64Attribute{Required: true, MarkdownDescription: "Root disk in GB. Changing this forces a new resource.", PlanModifiers: fnInt},
"flavor_id": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The desired flavor ID (auto-generated if omitted). Changing this forces a new resource.", PlanModifiers: fnC},
"swap": schema.Int64Attribute{Optional: true, Computed: true, Default: int64default.StaticInt64(0), MarkdownDescription: "Swap space in MB. Changing this forces a new resource.", PlanModifiers: fnInt},
"rx_tx_factor": schema.Float64Attribute{Optional: true, Computed: true, MarkdownDescription: "RX/TX factor. Changing this forces a new resource.", PlanModifiers: []planmodifier.Float64{float64planmodifier.RequiresReplace(), float64planmodifier.UseStateForUnknown()}},
"is_public": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true), MarkdownDescription: "Whether the flavor is public. Changing this forces a new resource.", PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()}},
"ephemeral": schema.Int64Attribute{Optional: true, Computed: true, Default: int64default.StaticInt64(0), MarkdownDescription: "Ephemeral disk in GB. Changing this forces a new resource.", PlanModifiers: fnInt},
"extra_specs": schema.MapAttribute{Optional: true, Computed: true, ElementType: types.StringType, MarkdownDescription: "Key/value extra specs (e.g. `hw:cpu_policy`). Can be added, changed, or removed on an existing flavor without replacing it."},
"region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region.", PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
},
}
Expand Down Expand Up @@ -121,7 +128,18 @@ func (r *flavorResource) Create(ctx context.Context, req resource.CreateRequest,
return
}

if specs := extractStringMap(ctx, plan.ExtraSpecs, &resp.Diagnostics); len(specs) > 0 {
if resp.Diagnostics.HasError() {
return
}
if _, err := flavors.CreateExtraSpecs(ctx, client, flavor.ID, flavors.ExtraSpecsOpts(specs)).Extract(); err != nil {
resp.Diagnostics.AddError("compute: setting flavor extra specs", err.Error())
return
}
}

r.flatten(flavor, &plan)
resp.Diagnostics.Append(r.refreshExtraSpecs(ctx, client, flavor.ID, &plan)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

Expand Down Expand Up @@ -151,16 +169,55 @@ func (r *flavorResource) Read(ctx context.Context, req resource.ReadRequest, res
}

r.flatten(flavor, &state)
resp.Diagnostics.Append(r.refreshExtraSpecs(ctx, client, state.ID.ValueString(), &state)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}

// Update is required by the interface but never invoked (flavors are immutable).
// Update reconciles extra_specs — the only in-place-mutable attribute (every
// other flavor field forces replacement).
func (r *flavorResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan flavorModel
var plan, state flavorModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

client, err := r.config.ComputeV2Client()
if err != nil {
resp.Diagnostics.AddError("compute: building v2 client", err.Error())
return
}

oldSpecs := extractStringMap(ctx, state.ExtraSpecs, &resp.Diagnostics)
newSpecs := extractStringMap(ctx, plan.ExtraSpecs, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}

id := plan.ID.ValueString()
for k := range oldSpecs {
if _, ok := newSpecs[k]; !ok {
if err := flavors.DeleteExtraSpec(ctx, client, id, k).ExtractErr(); err != nil {
resp.Diagnostics.AddError("compute: deleting flavor extra spec", err.Error())
return
}
}
}
changed := map[string]string{}
for k, v := range newSpecs {
if oldSpecs[k] != v {
changed[k] = v
}
}
if len(changed) > 0 {
if _, err := flavors.CreateExtraSpecs(ctx, client, id, flavors.ExtraSpecsOpts(changed)).Extract(); err != nil {
resp.Diagnostics.AddError("compute: updating flavor extra specs", err.Error())
return
}
}

resp.Diagnostics.Append(r.refreshExtraSpecs(ctx, client, id, &plan)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}

Expand Down Expand Up @@ -189,6 +246,24 @@ func (r *flavorResource) ImportState(ctx context.Context, req resource.ImportSta
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}

// refreshExtraSpecs lists the flavor's extra specs and writes them onto the
// model as an (always non-null) map, so state reflects the server.
func (r *flavorResource) refreshExtraSpecs(ctx context.Context, client *gophercloud.ServiceClient, id string, m *flavorModel) diag.Diagnostics {
var diags diag.Diagnostics
specs, err := flavors.ListExtraSpecs(ctx, client, id).Extract()
if err != nil {
diags.AddError("compute: listing flavor extra specs", err.Error())
return diags
}
if specs == nil {
specs = map[string]string{}
}
sm, d := types.MapValueFrom(ctx, types.StringType, specs)
diags.Append(d...)
m.ExtraSpecs = sm
return diags
}

func (r *flavorResource) flatten(flavor *flavors.Flavor, m *flavorModel) {
m.ID = types.StringValue(flavor.ID)
m.FlavorID = types.StringValue(flavor.ID)
Expand Down
67 changes: 67 additions & 0 deletions internal/services/compute/flavor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,73 @@ data "pcd_compute_flavor" "by_name" {
}
`

// TestAccComputeFlavor_extraSpecs verifies extra_specs can be added, changed, and
// removed in place (without replacing the flavor).
func TestAccComputeFlavor_extraSpecs(t *testing.T) {
const rn = "pcd_compute_flavor.test"
var flavorID string

resource.Test(t, resource.TestCase{
PreCheck: func() { acctest.PreCheck(t) },
ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories,
CheckDestroy: testAccCheckFlavorDestroy(t),
Steps: []resource.TestStep{
{
Config: testAccFlavorExtraSpecsConfig(`extra_specs = { "hw:cpu_policy" = "shared", "hw:numa_nodes" = "1" }`),
Check: resource.ComposeAggregateTestCheckFunc(
testAccCheckFlavorExists(t, rn),
testAccCaptureID(rn, &flavorID),
resource.TestCheckResourceAttr(rn, "extra_specs.%", "2"),
resource.TestCheckResourceAttr(rn, "extra_specs.hw:cpu_policy", "shared"),
resource.TestCheckResourceAttr(rn, "extra_specs.hw:numa_nodes", "1"),
),
},
{
// Replace cpu_policy, remove numa_nodes, add mem_page_size.
Config: testAccFlavorExtraSpecsConfig(`extra_specs = { "hw:cpu_policy" = "dedicated", "hw:mem_page_size" = "large" }`),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrWith(rn, "id", func(v string) error {
if v != flavorID {
return fmt.Errorf("flavor was replaced (%s -> %s); extra_specs should update in place", flavorID, v)
}
return nil
}),
resource.TestCheckResourceAttr(rn, "extra_specs.%", "2"),
resource.TestCheckResourceAttr(rn, "extra_specs.hw:cpu_policy", "dedicated"),
resource.TestCheckResourceAttr(rn, "extra_specs.hw:mem_page_size", "large"),
resource.TestCheckNoResourceAttr(rn, "extra_specs.hw:numa_nodes"),
),
},
{ResourceName: rn, ImportState: true, ImportStateVerify: true},
},
})
}

func testAccFlavorExtraSpecsConfig(specs string) string {
return fmt.Sprintf(`
resource "pcd_compute_flavor" "test" {
name = "tf-acc-flavor-es"
ram = 256
vcpus = 1
disk = 1
%s
}
`, specs)
}

// testAccCaptureID records a resource's ID for later comparison (e.g. asserting
// the resource was not replaced across steps).
func testAccCaptureID(n string, dst *string) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs := s.RootModule().Resources[n]
if rs == nil {
return fmt.Errorf("not found in state: %s", n)
}
*dst = rs.Primary.ID
return nil
}
}

func testAccCheckFlavorExists(t *testing.T, n string) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs := s.RootModule().Resources[n]
Expand Down
Loading
Loading