Skip to content

Commit 5afe18c

Browse files
authored
Merge pull request #4 from platform9/feat/images
feat(images): Glance image resource and data sources
2 parents 4da2e84 + 8dbe664 commit 5afe18c

7 files changed

Lines changed: 909 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ All notable changes to this project are documented here. The format is based on
2525
- Identity (Keystone v3) resources: `pcd_identity_project`, `pcd_identity_role`,
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`.
28+
- 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.
2831

2932
### Known gaps
3033
- `cloud` (clouds.yaml) is declared but not yet implemented; it errors if set.

internal/clients/config.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,28 @@ func (c *Config) IdentityV3Client() (*gophercloud.ServiceClient, error) {
191191
return client, nil
192192
}
193193

194+
// ImageV2Client returns a Glance v2 service client, honoring an endpoint_overrides
195+
// entry for the "image" service type if present.
196+
func (c *Config) ImageV2Client() (*gophercloud.ServiceClient, error) {
197+
client, err := openstack.NewImageV2(c.Provider, c.endpointOpts())
198+
if err != nil {
199+
return nil, fmt.Errorf("pcd: creating image v2 client: %w", err)
200+
}
201+
c.applyOverride(client, "image")
202+
return client, nil
203+
}
204+
205+
// NetworkV2Client returns a Neutron v2 service client, honoring an
206+
// endpoint_overrides entry for the "network" service type if present.
207+
func (c *Config) NetworkV2Client() (*gophercloud.ServiceClient, error) {
208+
client, err := openstack.NewNetworkV2(c.Provider, c.endpointOpts())
209+
if err != nil {
210+
return nil, fmt.Errorf("pcd: creating network v2 client: %w", err)
211+
}
212+
c.applyOverride(client, "network")
213+
return client, nil
214+
}
215+
194216
// applyOverride points a service client at an operator-supplied endpoint when
195217
// endpoint_overrides names its service type.
196218
func (c *Config) applyOverride(client *gophercloud.ServiceClient, serviceType string) {

internal/provider/provider.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/hashicorp/terraform-plugin-framework/resource"
1212

1313
"github.com/platform9/terraform-provider-pcd/internal/services/identity"
14+
"github.com/platform9/terraform-provider-pcd/internal/services/images"
1415
)
1516

1617
// Ensure pcdProvider satisfies the provider.Provider interface.
@@ -42,6 +43,7 @@ func (p *pcdProvider) Resources(_ context.Context) []func() resource.Resource {
4243
identity.NewUserResource,
4344
identity.NewRoleAssignmentResource,
4445
identity.NewApplicationCredentialResource,
46+
images.NewImageResource,
4547
}
4648
}
4749

@@ -51,5 +53,7 @@ func (p *pcdProvider) DataSources(_ context.Context) []func() datasource.DataSou
5153
identity.NewProjectDataSource,
5254
identity.NewUserDataSource,
5355
identity.NewRoleDataSource,
56+
images.NewImageDataSource,
57+
images.NewImageIDsDataSource,
5458
}
5559
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) Platform9 Systems, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
//
4+
// Ported from terraform-provider-openstack v3.4.0
5+
// (openstack/data_source_openstack_images_image_v2.go), adapted for the
6+
// terraform-plugin-framework and PCD.
7+
8+
package images
9+
10+
import (
11+
"context"
12+
"fmt"
13+
"sort"
14+
"time"
15+
16+
"github.com/gophercloud/gophercloud/v2/openstack/image/v2/images"
17+
"github.com/hashicorp/terraform-plugin-framework/datasource"
18+
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
19+
"github.com/hashicorp/terraform-plugin-framework/types"
20+
21+
"github.com/platform9/terraform-provider-pcd/internal/clients"
22+
)
23+
24+
var (
25+
_ datasource.DataSource = (*imageDataSource)(nil)
26+
_ datasource.DataSourceWithConfigure = (*imageDataSource)(nil)
27+
)
28+
29+
// NewImageDataSource is the factory registered with the provider.
30+
func NewImageDataSource() datasource.DataSource {
31+
return &imageDataSource{}
32+
}
33+
34+
type imageDataSource struct {
35+
config *clients.Config
36+
}
37+
38+
type imageDataSourceModel struct {
39+
ID types.String `tfsdk:"id"`
40+
ImageID types.String `tfsdk:"image_id"`
41+
Name types.String `tfsdk:"name"`
42+
Owner types.String `tfsdk:"owner"`
43+
Tag types.String `tfsdk:"tag"`
44+
Visibility types.String `tfsdk:"visibility"`
45+
MostRecent types.Bool `tfsdk:"most_recent"`
46+
ContainerFormat types.String `tfsdk:"container_format"`
47+
DiskFormat types.String `tfsdk:"disk_format"`
48+
MinDiskGB types.Int64 `tfsdk:"min_disk_gb"`
49+
MinRAMMB types.Int64 `tfsdk:"min_ram_mb"`
50+
Protected types.Bool `tfsdk:"protected"`
51+
Checksum types.String `tfsdk:"checksum"`
52+
SizeBytes types.Int64 `tfsdk:"size_bytes"`
53+
CreatedAt types.String `tfsdk:"created_at"`
54+
UpdatedAt types.String `tfsdk:"updated_at"`
55+
Tags types.Set `tfsdk:"tags"`
56+
Region types.String `tfsdk:"region"`
57+
}
58+
59+
func (d *imageDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
60+
resp.TypeName = req.ProviderTypeName + "_images_image"
61+
}
62+
63+
func (d *imageDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
64+
resp.Schema = schema.Schema{
65+
MarkdownDescription: "Look up a single image in PCD's Glance service by id or by filters.",
66+
Attributes: map[string]schema.Attribute{
67+
"id": schema.StringAttribute{Computed: true, MarkdownDescription: "The image ID."},
68+
"image_id": schema.StringAttribute{Optional: true, MarkdownDescription: "Look up by image ID (takes precedence over filters)."},
69+
"name": schema.StringAttribute{Optional: true, MarkdownDescription: "Filter by exact image name."},
70+
"owner": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Filter by (and report) the owning project."},
71+
"tag": schema.StringAttribute{Optional: true, MarkdownDescription: "Filter by a required tag."},
72+
"visibility": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "Filter by (and report) visibility."},
73+
"most_recent": schema.BoolAttribute{Optional: true, MarkdownDescription: "If multiple images match, select the most recently created."},
74+
"container_format": schema.StringAttribute{Computed: true, MarkdownDescription: "The container format."},
75+
"disk_format": schema.StringAttribute{Computed: true, MarkdownDescription: "The disk format."},
76+
"min_disk_gb": schema.Int64Attribute{Computed: true, MarkdownDescription: "Minimum disk (GB)."},
77+
"min_ram_mb": schema.Int64Attribute{Computed: true, MarkdownDescription: "Minimum RAM (MB)."},
78+
"protected": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the image is protected."},
79+
"checksum": schema.StringAttribute{Computed: true, MarkdownDescription: "md5 checksum."},
80+
"size_bytes": schema.Int64Attribute{Computed: true, MarkdownDescription: "Size in bytes."},
81+
"created_at": schema.StringAttribute{Computed: true, MarkdownDescription: "Creation timestamp (RFC3339)."},
82+
"updated_at": schema.StringAttribute{Computed: true, MarkdownDescription: "Last-update timestamp (RFC3339)."},
83+
"tags": schema.SetAttribute{Computed: true, ElementType: types.StringType, MarkdownDescription: "Image tags."},
84+
"region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region."},
85+
},
86+
}
87+
}
88+
89+
func (d *imageDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
90+
if req.ProviderData == nil {
91+
return
92+
}
93+
config, ok := req.ProviderData.(*clients.Config)
94+
if !ok {
95+
resp.Diagnostics.AddError("Unexpected provider data type",
96+
fmt.Sprintf("Expected *clients.Config, got %T.", req.ProviderData))
97+
return
98+
}
99+
d.config = config
100+
}
101+
102+
func (d *imageDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
103+
var data imageDataSourceModel
104+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
105+
if resp.Diagnostics.HasError() {
106+
return
107+
}
108+
109+
client, err := d.config.ImageV2Client()
110+
if err != nil {
111+
resp.Diagnostics.AddError("images: building v2 client", err.Error())
112+
return
113+
}
114+
115+
var img *images.Image
116+
if v := data.ImageID.ValueString(); v != "" {
117+
img, err = images.Get(ctx, client, v).Extract()
118+
if err != nil {
119+
resp.Diagnostics.AddError("images: getting image by id", err.Error())
120+
return
121+
}
122+
} else {
123+
listOpts := images.ListOpts{
124+
Name: data.Name.ValueString(),
125+
Owner: data.Owner.ValueString(),
126+
}
127+
if v := data.Visibility.ValueString(); v != "" {
128+
listOpts.Visibility = images.ImageVisibility(v)
129+
}
130+
if v := data.Tag.ValueString(); v != "" {
131+
listOpts.Tags = []string{v}
132+
}
133+
pages, err := images.List(client, listOpts).AllPages(ctx)
134+
if err != nil {
135+
resp.Diagnostics.AddError("images: listing images", err.Error())
136+
return
137+
}
138+
all, err := images.ExtractImages(pages)
139+
if err != nil {
140+
resp.Diagnostics.AddError("images: extracting images", err.Error())
141+
return
142+
}
143+
switch {
144+
case len(all) == 0:
145+
resp.Diagnostics.AddError("No image found", "No image matched the given criteria.")
146+
return
147+
case len(all) == 1:
148+
img = &all[0]
149+
case data.MostRecent.ValueBool():
150+
sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt.After(all[j].CreatedAt) })
151+
img = &all[0]
152+
default:
153+
resp.Diagnostics.AddError("Multiple images found",
154+
fmt.Sprintf("%d images matched; set most_recent or refine the filters.", len(all)))
155+
return
156+
}
157+
}
158+
159+
data.ID = types.StringValue(img.ID)
160+
data.Name = types.StringValue(img.Name)
161+
data.Owner = types.StringValue(img.Owner)
162+
data.Visibility = types.StringValue(string(img.Visibility))
163+
data.ContainerFormat = types.StringValue(img.ContainerFormat)
164+
data.DiskFormat = types.StringValue(img.DiskFormat)
165+
data.MinDiskGB = types.Int64Value(int64(img.MinDiskGigabytes))
166+
data.MinRAMMB = types.Int64Value(int64(img.MinRAMMegabytes))
167+
data.Protected = types.BoolValue(img.Protected)
168+
data.Checksum = types.StringValue(img.Checksum)
169+
data.SizeBytes = types.Int64Value(img.SizeBytes)
170+
data.CreatedAt = types.StringValue(img.CreatedAt.Format(time.RFC3339))
171+
data.UpdatedAt = types.StringValue(img.UpdatedAt.Format(time.RFC3339))
172+
173+
tagVals := img.Tags
174+
if tagVals == nil {
175+
tagVals = []string{}
176+
}
177+
tags, diags := types.SetValueFrom(ctx, types.StringType, tagVals)
178+
resp.Diagnostics.Append(diags...)
179+
data.Tags = tags
180+
181+
if data.Region.IsNull() || data.Region.IsUnknown() {
182+
data.Region = types.StringValue(d.config.Region)
183+
}
184+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
185+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) Platform9 Systems, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
//
4+
// Ported from terraform-provider-openstack v3.4.0
5+
// (openstack/data_source_openstack_images_image_ids_v2.go), adapted for the
6+
// terraform-plugin-framework and PCD.
7+
8+
package images
9+
10+
import (
11+
"context"
12+
"fmt"
13+
14+
"github.com/gophercloud/gophercloud/v2/openstack/image/v2/images"
15+
"github.com/hashicorp/terraform-plugin-framework/datasource"
16+
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
17+
"github.com/hashicorp/terraform-plugin-framework/types"
18+
19+
"github.com/platform9/terraform-provider-pcd/internal/clients"
20+
)
21+
22+
var (
23+
_ datasource.DataSource = (*imageIDsDataSource)(nil)
24+
_ datasource.DataSourceWithConfigure = (*imageIDsDataSource)(nil)
25+
)
26+
27+
// NewImageIDsDataSource is the factory registered with the provider.
28+
func NewImageIDsDataSource() datasource.DataSource {
29+
return &imageIDsDataSource{}
30+
}
31+
32+
type imageIDsDataSource struct {
33+
config *clients.Config
34+
}
35+
36+
type imageIDsDataSourceModel struct {
37+
ID types.String `tfsdk:"id"`
38+
Name types.String `tfsdk:"name"`
39+
Owner types.String `tfsdk:"owner"`
40+
Tag types.String `tfsdk:"tag"`
41+
Visibility types.String `tfsdk:"visibility"`
42+
IDs types.List `tfsdk:"ids"`
43+
Region types.String `tfsdk:"region"`
44+
}
45+
46+
func (d *imageIDsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
47+
resp.TypeName = req.ProviderTypeName + "_images_image_ids"
48+
}
49+
50+
func (d *imageIDsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
51+
resp.Schema = schema.Schema{
52+
MarkdownDescription: "Return the IDs of images in PCD's Glance service matching the given filters.",
53+
Attributes: map[string]schema.Attribute{
54+
"id": schema.StringAttribute{Computed: true, MarkdownDescription: "Data source identifier."},
55+
"name": schema.StringAttribute{Optional: true, MarkdownDescription: "Filter by exact image name."},
56+
"owner": schema.StringAttribute{Optional: true, MarkdownDescription: "Filter by owning project."},
57+
"tag": schema.StringAttribute{Optional: true, MarkdownDescription: "Filter by a required tag."},
58+
"visibility": schema.StringAttribute{Optional: true, MarkdownDescription: "Filter by visibility."},
59+
"ids": schema.ListAttribute{Computed: true, ElementType: types.StringType, MarkdownDescription: "The matching image IDs."},
60+
"region": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: "The region. Defaults to the provider's region."},
61+
},
62+
}
63+
}
64+
65+
func (d *imageIDsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
66+
if req.ProviderData == nil {
67+
return
68+
}
69+
config, ok := req.ProviderData.(*clients.Config)
70+
if !ok {
71+
resp.Diagnostics.AddError("Unexpected provider data type",
72+
fmt.Sprintf("Expected *clients.Config, got %T.", req.ProviderData))
73+
return
74+
}
75+
d.config = config
76+
}
77+
78+
func (d *imageIDsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
79+
var data imageIDsDataSourceModel
80+
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
81+
if resp.Diagnostics.HasError() {
82+
return
83+
}
84+
85+
client, err := d.config.ImageV2Client()
86+
if err != nil {
87+
resp.Diagnostics.AddError("images: building v2 client", err.Error())
88+
return
89+
}
90+
91+
listOpts := images.ListOpts{
92+
Name: data.Name.ValueString(),
93+
Owner: data.Owner.ValueString(),
94+
}
95+
if v := data.Visibility.ValueString(); v != "" {
96+
listOpts.Visibility = images.ImageVisibility(v)
97+
}
98+
if v := data.Tag.ValueString(); v != "" {
99+
listOpts.Tags = []string{v}
100+
}
101+
102+
pages, err := images.List(client, listOpts).AllPages(ctx)
103+
if err != nil {
104+
resp.Diagnostics.AddError("images: listing images", err.Error())
105+
return
106+
}
107+
all, err := images.ExtractImages(pages)
108+
if err != nil {
109+
resp.Diagnostics.AddError("images: extracting images", err.Error())
110+
return
111+
}
112+
113+
ids := make([]string, 0, len(all))
114+
for _, img := range all {
115+
ids = append(ids, img.ID)
116+
}
117+
idList, diags := types.ListValueFrom(ctx, types.StringType, ids)
118+
resp.Diagnostics.Append(diags...)
119+
data.IDs = idList
120+
121+
if data.Region.IsNull() || data.Region.IsUnknown() {
122+
data.Region = types.StringValue(d.config.Region)
123+
}
124+
data.ID = types.StringValue(d.config.Region + ":images")
125+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
126+
}

0 commit comments

Comments
 (0)