0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-06-29 18:21:10 +00:00

feat(access): add ACL resource (#1166)

* feat: add ACL resource

Signed-off-by: hrmny <8845940+ForsakenHarmony@users.noreply.github.com>

* chore: move code under /access, cleanup acc tests

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>

---------

Signed-off-by: hrmny <8845940+ForsakenHarmony@users.noreply.github.com>
Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
This commit is contained in:
hrmny 2024-05-09 02:22:15 +02:00 committed by GitHub
parent 8220271eee
commit afcbb415a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 773 additions and 167 deletions

View File

@ -94,7 +94,7 @@ test:
.PHONY: testacc
testacc:
@# explicitly add TF_ACC=1 to trigger the acceptance tests, `testacc.env` might be missing or incomplete
@# explicitly add TF_ACC=1 to trigger the acceptance tests, `testacc.env` might be missing or incomplete
@TF_ACC=1 env $$(cat testacc.env | xargs) go test --timeout=30m -count=1 -v github.com/bpg/terraform-provider-proxmox/fwprovider/tests/...
.PHONY: lint

View File

@ -0,0 +1,72 @@
---
layout: page
title: proxmox_virtual_environment_acl
parent: Resources
subcategory: Virtual Environment
description: |-
Manages ACLs on the Proxmox cluster.
ACLs are used to control access to resources in the Proxmox cluster.
Each ACL consists of a path, a user, group or token, a role, and a flag to allow propagation of permissions.
---
# Resource: proxmox_virtual_environment_acl
Manages ACLs on the Proxmox cluster.
ACLs are used to control access to resources in the Proxmox cluster.
Each ACL consists of a path, a user, group or token, a role, and a flag to allow propagation of permissions.
## Example Usage
```terraform
resource "proxmox_virtual_environment_user" "operations_automation" {
comment = "Managed by Terraform"
password = "a-strong-password"
user_id = "operations-automation@pve"
}
resource "proxmox_virtual_environment_role" "operations_monitoring" {
role_id = "operations-monitoring"
privileges = [
"VM.Monitor",
]
}
resource "proxmox_virtual_environment_acl" "operations_automation_monitoring" {
user_id = proxmox_virtual_environment_user.operations_automation.user_id
role_id = proxmox_virtual_environment_role.operations_monitoring.role_id
path = "/vms/1234"
propagate = true
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `path` (String) Access control path
- `role_id` (String) The role to apply
### Optional
- `group_id` (String) The group the ACL should apply to (mutually exclusive with `token_id` and `user_id`)
- `propagate` (Boolean) Allow to propagate (inherit) permissions.
- `token_id` (String) The token the ACL should apply to (mutually exclusive with `group_id` and `user_id`)
- `user_id` (String) The user the ACL should apply to (mutually exclusive with `group_id` and `token_id`)
### Read-Only
- `id` (String) The unique identifier of this resource.
## Import
Import is supported using the following syntax:
```shell
#!/usr/bin/env sh
# ACL can be imported using its unique identifier, e.g.: {path}?entity_id={group|user@realm|user@realm!token}?role_id={role}
terraform import proxmox_virtual_environment_acl.operations_automation_monitoring /?entity_id=monitor@pve&role_id=operations-monitoring
```

View File

@ -0,0 +1,3 @@
#!/usr/bin/env sh
# ACL can be imported using its unique identifier, e.g.: {path}?entity_id={group|user@realm|user@realm!token}?role_id={role}
terraform import proxmox_virtual_environment_acl.operations_automation_monitoring /?entity_id=monitor@pve&role_id=operations-monitoring

View File

@ -0,0 +1,21 @@
resource "proxmox_virtual_environment_user" "operations_automation" {
comment = "Managed by Terraform"
password = "a-strong-password"
user_id = "operations-automation@pve"
}
resource "proxmox_virtual_environment_role" "operations_monitoring" {
role_id = "operations-monitoring"
privileges = [
"VM.Monitor",
]
}
resource "proxmox_virtual_environment_acl" "operations_automation_monitoring" {
user_id = proxmox_virtual_environment_user.operations_automation.user_id
role_id = proxmox_virtual_environment_role.operations_monitoring.role_id
path = "/vms/1234"
propagate = true
}

View File

@ -0,0 +1,274 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package access
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator"
"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/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/bpg/terraform-provider-proxmox/fwprovider/structure"
"github.com/bpg/terraform-provider-proxmox/proxmox"
"github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr"
proxmoxtypes "github.com/bpg/terraform-provider-proxmox/proxmox/types"
)
var (
_ resource.Resource = (*aclResource)(nil)
_ resource.ResourceWithConfigure = (*aclResource)(nil)
_ resource.ResourceWithImportState = (*aclResource)(nil)
_ resource.ResourceWithConfigValidators = (*aclResource)(nil)
)
type aclResource struct {
client proxmox.Client
}
// NewACLResource creates a new ACL resource.
func NewACLResource() resource.Resource {
return &aclResource{}
}
func (r *aclResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Manages ACLs on the Proxmox cluster",
MarkdownDescription: "Manages ACLs on the Proxmox cluster.\n\n" +
"ACLs are used to control access to resources in the Proxmox cluster.\n" +
"Each ACL consists of a path, a user, group or token, a role, and a flag to allow propagation of permissions.",
Attributes: map[string]schema.Attribute{
"group_id": schema.StringAttribute{
Description: "The group the ACL should apply to (mutually exclusive with `token_id` and `user_id`)",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"id": structure.IDAttribute(),
"path": schema.StringAttribute{
Description: "Access control path",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"propagate": schema.BoolAttribute{
Description: "Allow to propagate (inherit) permissions.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
},
"role_id": schema.StringAttribute{
Description: "The role to apply",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"token_id": schema.StringAttribute{
Description: "The token the ACL should apply to (mutually exclusive with `group_id` and `user_id`)",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"user_id": schema.StringAttribute{
Description: "The user the ACL should apply to (mutually exclusive with `group_id` and `token_id`)",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
},
}
}
func (r *aclResource) ConfigValidators(_ context.Context) []resource.ConfigValidator {
return []resource.ConfigValidator{
resourcevalidator.Conflicting(
path.MatchRoot("group_id"),
path.MatchRoot("token_id"),
path.MatchRoot("user_id"),
),
}
}
func (r *aclResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(proxmox.Client)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *proxmox.Client, got: %T. Please report this issue to the provider developers.",
req.ProviderData),
)
return
}
r.client = client
}
func (r *aclResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_acl"
}
func (r *aclResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan aclResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
body := plan.intoUpdateBody()
err := r.client.Access().UpdateACL(ctx, body)
if err != nil {
resp.Diagnostics.AddError("Unable to create ACL", apiCallFailed+err.Error())
return
}
err = plan.generateID()
if err != nil {
resp.Diagnostics.AddError("Unable to create ACL", "failed to generate ID: "+err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
func (r *aclResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state aclResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
acls, err := r.client.Access().GetACL(ctx)
if err != nil {
resp.Diagnostics.AddError("Unable read ACL", apiCallFailed+err.Error())
return
}
for _, acl := range acls {
switch acl.Type {
case "group":
if acl.UserOrGroupID != state.GroupID.ValueString() {
continue
}
case "token":
if acl.UserOrGroupID != state.TokenID.ValueString() {
continue
}
case "user":
if acl.UserOrGroupID != state.UserID.ValueString() {
continue
}
default:
// ignore unknown values
continue
}
if acl.Path != state.Path {
continue
}
if acl.RoleID != state.RoleID {
continue
}
state.Propagate = ptr.Or(acl.Propagate.PointerBool(), true)
resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
return
}
resp.State.RemoveResource(ctx)
}
func (r *aclResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var (
state aclResourceModel
plan aclResourceModel
)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
stateBody := state.intoUpdateBody()
stateBody.Delete = proxmoxtypes.CustomBool(true).Pointer()
err := r.client.Access().UpdateACL(ctx, stateBody)
if err != nil {
resp.Diagnostics.AddError("Unable to delete old ACL", apiCallFailed+err.Error())
return
}
planBody := plan.intoUpdateBody()
err = r.client.Access().UpdateACL(ctx, planBody)
if err != nil {
resp.Diagnostics.AddError("Unable to create ACL", apiCallFailed+err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
}
func (r *aclResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state aclResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
stateBody := state.intoUpdateBody()
stateBody.Delete = proxmoxtypes.CustomBool(true).Pointer()
err := r.client.Access().UpdateACL(ctx, stateBody)
if err != nil {
resp.Diagnostics.AddError("Unable to delete old ACL", apiCallFailed+err.Error())
return
}
}
func (r *aclResource) ImportState(
ctx context.Context,
req resource.ImportStateRequest,
resp *resource.ImportStateResponse,
) {
model, err := parseACLResourceModelFromID(req.ID)
if err != nil {
resp.Diagnostics.AddError("Unable to import ACL", "failed to parse ID: "+err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
}
const apiCallFailed = "API call failed: "

View File

@ -0,0 +1,125 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package access
import (
"fmt"
"net/url"
"strings"
"github.com/gorilla/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
proxmoxtypes "github.com/bpg/terraform-provider-proxmox/proxmox/types"
"github.com/bpg/terraform-provider-proxmox/proxmox/access"
)
type aclResourceModel struct {
ID types.String `tfsdk:"id"`
GroupID types.String `tfsdk:"group_id"`
Path string `tfsdk:"path"`
Propagate bool `tfsdk:"propagate"`
RoleID string `tfsdk:"role_id"`
TokenID types.String `tfsdk:"token_id"`
UserID types.String `tfsdk:"user_id"`
}
type aclResourceIDFields struct {
EntityID string `schema:"entity_id"`
RoleID string `schema:"role_id"`
}
const aclIDFormat = "{path}?entity_id={group|user@realm|user@realm!token}?role_id={role}"
func (r *aclResourceModel) generateID() error {
encoder := schema.NewEncoder()
fields := aclResourceIDFields{
EntityID: r.GroupID.ValueString() + r.TokenID.ValueString() + r.UserID.ValueString(),
RoleID: r.RoleID,
}
v := url.Values{}
err := encoder.Encode(fields, v)
if err != nil {
return fmt.Errorf("failed to encode ACL resource ID: %w", err)
}
r.ID = types.StringValue(r.Path + "?" + v.Encode())
return nil
}
func parseACLResourceModelFromID(id string) (*aclResourceModel, error) {
path, query, found := strings.Cut(id, "?")
if !found {
return nil, fmt.Errorf("invalid ACL resource ID format %#v, expected %v", id, aclIDFormat)
}
v, err := url.ParseQuery(query)
if err != nil {
return nil, fmt.Errorf("invalid ACL resource ID format %#v, expected %v: %w", id, aclIDFormat, err)
}
decoder := schema.NewDecoder()
fields := aclResourceIDFields{}
err = decoder.Decode(&fields, v)
if err != nil {
return nil, fmt.Errorf("invalid ACL resource ID format %#v, expected %v: %w", id, aclIDFormat, err)
}
model := &aclResourceModel{
ID: types.StringValue(id),
GroupID: types.StringNull(),
Path: path,
Propagate: false,
RoleID: fields.RoleID,
TokenID: types.StringNull(),
UserID: types.StringNull(),
}
switch {
case strings.Contains(fields.EntityID, "!"):
model.TokenID = types.StringValue(fields.EntityID)
case strings.Contains(fields.EntityID, "@"):
model.UserID = types.StringValue(fields.EntityID)
default:
model.GroupID = types.StringValue(fields.EntityID)
}
return model, nil
}
func (r *aclResourceModel) intoUpdateBody() *access.ACLUpdateRequestBody {
body := &access.ACLUpdateRequestBody{
Groups: nil,
Path: r.Path,
Propagate: proxmoxtypes.CustomBoolPtr(r.Propagate),
Roles: []string{r.RoleID},
Tokens: nil,
Users: nil,
}
if !r.GroupID.IsNull() {
body.Groups = []string{r.GroupID.ValueString()}
}
if !r.TokenID.IsNull() {
body.Tokens = []string{r.TokenID.ValueString()}
}
if !r.UserID.IsNull() {
body.Users = []string{r.UserID.ValueString()}
}
return body
}

View File

@ -144,7 +144,7 @@ func (r *userTokenResource) Create(ctx context.Context, req resource.CreateReque
body := access.UserTokenCreateRequestBody{
Comment: plan.Comment.ValueStringPointer(),
PrivSeparate: proxmoxtypes.CustomBoolPtr(plan.PrivSeparation.ValueBoolPointer()),
PrivSeparate: proxmoxtypes.CustomBoolPtr(plan.PrivSeparation.ValueBool()),
}
if !plan.ExpirationDate.IsNull() && plan.ExpirationDate.ValueString() != "" {
@ -215,7 +215,7 @@ func (r *userTokenResource) Update(ctx context.Context, req resource.UpdateReque
body := access.UserTokenUpdateRequestBody{
Comment: plan.Comment.ValueStringPointer(),
PrivSeparate: proxmoxtypes.CustomBoolPtr(plan.PrivSeparation.ValueBoolPointer()),
PrivSeparate: proxmoxtypes.CustomBoolPtr(plan.PrivSeparation.ValueBool()),
}
if !plan.ExpirationDate.IsNull() && plan.ExpirationDate.ValueString() != "" {

View File

@ -451,6 +451,7 @@ func (p *proxmoxProvider) Resources(_ context.Context) []func() resource.Resourc
vm.NewVMResource,
NewClusterOptionsResource,
NewDownloadFileResource,
access.NewACLResource,
}
}

View File

@ -15,11 +15,11 @@ import (
// IDAttribute generates an attribute definition suitable for the always-present `id` attribute.
func IDAttribute(desc ...string) schema.StringAttribute {
attr := schema.StringAttribute{
Computed: true,
Computed: true,
Description: "The unique identifier of this resource.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Description: "The unique identifier of this resource.",
}
if len(desc) > 0 {

View File

@ -0,0 +1,174 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package tests
import (
"context"
"fmt"
"net/url"
"regexp"
"testing"
"github.com/brianvoe/gofakeit/v7"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/stretchr/testify/require"
"github.com/bpg/terraform-provider-proxmox/proxmox/access"
)
func TestAccAcl_User(t *testing.T) {
te := initTestEnvironment(t)
userID := fmt.Sprintf("%s@pve", gofakeit.Username())
te.addTemplateVars(map[string]any{
"UserID": userID,
})
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: te.accProviders,
CheckDestroy: nil,
PreCheck: func() {
err := te.accessClient().CreateUser(context.Background(), &access.UserCreateRequestBody{
ID: userID,
Password: gofakeit.Password(true, true, true, true, false, 8),
})
require.NoError(t, err)
t.Cleanup(func() {
err := te.accessClient().DeleteUser(context.Background(), userID)
require.NoError(t, err)
})
},
Steps: []resource.TestStep{
{
Config: te.renderConfig(`resource "proxmox_virtual_environment_acl" "test" {
user_id = "{{.UserID}}"
path = "/"
role_id = "NoAccess"
}`),
Check: resource.ComposeTestCheckFunc(
testResourceAttributes("proxmox_virtual_environment_acl.test", map[string]string{
"path": "/",
"role_id": "NoAccess",
"user_id": userID,
"propagate": "true",
}),
testNoResourceAttributesSet("proxmox_virtual_environment_acl.test", []string{
"group_id",
"token_id",
}),
),
},
{
ResourceName: "proxmox_virtual_environment_acl.test",
ImportState: true,
ImportStateIdFunc: testAccACLImportStateIDFunc(),
ImportStateVerify: true,
},
{
Config: te.renderConfig(`resource "proxmox_virtual_environment_acl" "test" {
user_id = "{{.UserID}}"
path = "/"
role_id = "PVEPoolUser"
}`),
Check: resource.ComposeTestCheckFunc(
testResourceAttributes("proxmox_virtual_environment_acl.test", map[string]string{
"path": "/",
"role_id": "PVEPoolUser",
"user_id": userID,
"propagate": "true",
}),
testNoResourceAttributesSet("proxmox_virtual_environment_acl.test", []string{
"group_id",
"token_id",
}),
),
},
},
})
}
func TestAccAcl_Validators(t *testing.T) {
t.Parallel()
te := initTestEnvironment(t)
resource.UnitTest(t, resource.TestCase{
ProtoV6ProviderFactories: te.accProviders,
CheckDestroy: nil,
Steps: []resource.TestStep{
{
PlanOnly: true,
Config: `resource "proxmox_virtual_environment_acl" "test" {
group_id = "test"
path = "/"
role_id = "test"
token_id = "test"
}`,
ExpectError: regexp.MustCompile(`.*Error: Invalid Attribute Combination`),
},
{
PlanOnly: true,
Config: `resource "proxmox_virtual_environment_acl" "test" {
path = "/"
role_id = "test"
token_id = "test"
user_id = "test"
}`,
ExpectError: regexp.MustCompile(`.*Error: Invalid Attribute Combination`),
},
{
PlanOnly: true,
Config: `resource "proxmox_virtual_environment_acl" "test" {
group_id = "test"
path = "/"
role_id = "test"
user_id = "test"
}`,
ExpectError: regexp.MustCompile(`.*Error: Invalid Attribute Combination`),
},
{
PlanOnly: true,
Config: `resource "proxmox_virtual_environment_acl" "test" {
group_id = "test"
path = "/"
role_id = "test"
token_id = "test"
user_id = "test"
}`,
ExpectError: regexp.MustCompile(`.*Error: Invalid Attribute Combination`),
},
},
})
}
func testAccACLImportStateIDFunc() resource.ImportStateIdFunc {
return func(s *terraform.State) (string, error) {
resourceName := "proxmox_virtual_environment_acl.test"
rs, ok := s.RootModule().Resources[resourceName]
if !ok {
return "", fmt.Errorf("not found: %s", resourceName)
}
path := rs.Primary.Attributes["path"]
groupID := rs.Primary.Attributes["group_id"]
tokenID := rs.Primary.Attributes["token_id"]
userID := rs.Primary.Attributes["user_id"]
roleID := rs.Primary.Attributes["role_id"]
v := url.Values{
"entity_id": []string{groupID + tokenID + userID},
"role_id": []string{roleID},
}
return path + "?" + v.Encode(), nil
}
}

View File

@ -14,8 +14,8 @@ import (
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/stretchr/testify/require"
"github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr"
"github.com/bpg/terraform-provider-proxmox/proxmox/nodes/storage"
"github.com/bpg/terraform-provider-proxmox/proxmox/types"
)
const (
@ -133,11 +133,11 @@ func TestAccResourceDownloadFile(t *testing.T) {
_ = te.nodeStorageClient().DeleteDatastoreFile(ctx, "iso/fake_file.iso") //nolint: errcheck
err := te.nodeStorageClient().DownloadFileByURL(ctx, &storage.DownloadURLPostRequestBody{
Content: types.StrPtr("iso"),
FileName: types.StrPtr("fake_file.iso"),
Node: types.StrPtr(te.nodeName),
Storage: types.StrPtr(te.datastoreID),
URL: types.StrPtr(fakeFileISO),
Content: ptr.Ptr("iso"),
FileName: ptr.Ptr("fake_file.iso"),
Node: ptr.Ptr(te.nodeName),
Storage: ptr.Ptr(te.datastoreID),
URL: ptr.Ptr(fakeFileISO),
})
require.NoError(t, err)

View File

@ -23,9 +23,9 @@ func TestAccResourceUser(t *testing.T) {
te := initTestEnvironment(t)
username := fmt.Sprintf("%s@pve", gofakeit.Username())
userID := fmt.Sprintf("%s@pve", gofakeit.Username())
te.addTemplateVars(map[string]any{
"Username": username,
"UserID": userID,
})
tests := []struct {
@ -36,35 +36,35 @@ func TestAccResourceUser(t *testing.T) {
{
Config: te.renderConfig(`resource "proxmox_virtual_environment_user" "user" {
comment = "Managed by Terraform"
email = "{{.Username}}"
email = "{{.UserID}}"
enabled = true
expiration_date = "2034-01-01T22:00:00Z"
first_name = "First"
last_name = "Last"
user_id = "{{.Username}}"
user_id = "{{.UserID}}"
}`),
Check: testResourceAttributes("proxmox_virtual_environment_user.user", map[string]string{
"comment": "Managed by Terraform",
"email": username,
"email": userID,
"enabled": "true",
"expiration_date": "2034-01-01T22:00:00Z",
"first_name": "First",
"last_name": "Last",
"user_id": username,
"user_id": userID,
}),
},
{
Config: te.renderConfig(`resource "proxmox_virtual_environment_user" "user" {
enabled = false
expiration_date = "2035-01-01T22:00:00Z"
user_id = "{{.Username}}"
user_id = "{{.UserID}}"
first_name = "First One"
}`),
Check: testResourceAttributes("proxmox_virtual_environment_user.user", map[string]string{
"enabled": "false",
"expiration_date": "2035-01-01T22:00:00Z",
"first_name": "First One",
"user_id": username,
"user_id": userID,
}),
},
{
@ -89,11 +89,11 @@ func TestAccResourceUserToken(t *testing.T) {
t.Parallel()
te := initTestEnvironment(t)
username := fmt.Sprintf("%s@pve", gofakeit.Username())
userID := fmt.Sprintf("%s@pve", gofakeit.Username())
tokenName := gofakeit.Word()
te.addTemplateVars(map[string]any{
"Username": username,
"UserID": userID,
"TokenName": tokenName,
})
@ -106,13 +106,13 @@ func TestAccResourceUserToken(t *testing.T) {
"create and update user token",
func() {
err := te.accessClient().CreateUser(context.Background(), &access.UserCreateRequestBody{
ID: username,
ID: userID,
Password: gofakeit.Password(true, true, true, true, false, 8),
})
require.NoError(t, err)
t.Cleanup(func() {
err := te.accessClient().DeleteUser(context.Background(), username)
err := te.accessClient().DeleteUser(context.Background(), userID)
require.NoError(t, err)
})
},
@ -122,14 +122,14 @@ func TestAccResourceUserToken(t *testing.T) {
comment = "Managed by Terraform"
expiration_date = "2034-01-01T22:00:00Z"
token_name = "{{.TokenName}}"
user_id = "{{.Username}}"
user_id = "{{.UserID}}"
}`),
Check: testResourceAttributes("proxmox_virtual_environment_user_token.user_token", map[string]string{
"comment": "Managed by Terraform",
"expiration_date": "2034-01-01T22:00:00Z",
"id": fmt.Sprintf("%s!%s", username, tokenName),
"user_id": username,
"value": fmt.Sprintf("%s!%s=.*", username, tokenName),
"id": fmt.Sprintf("%s!%s", userID, tokenName),
"user_id": userID,
"value": fmt.Sprintf("%s!%s=.*", userID, tokenName),
}),
},
{
@ -138,7 +138,7 @@ func TestAccResourceUserToken(t *testing.T) {
expiration_date = "2033-01-01T01:01:01Z"
privileges_separation = false
token_name = "{{.TokenName}}"
user_id = "{{.Username}}"
user_id = "{{.UserID}}"
}`),
Check: resource.ComposeTestCheckFunc(
testResourceAttributes("proxmox_virtual_environment_user_token.user_token", map[string]string{
@ -146,7 +146,7 @@ func TestAccResourceUserToken(t *testing.T) {
"expiration_date": "2033-01-01T01:01:01Z",
"privileges_separation": "false",
"token_name": tokenName,
"user_id": username,
"user_id": userID,
}),
testNoResourceAttributesSet("proxmox_virtual_environment_user_token.user_token", []string{
"value",

View File

@ -9,7 +9,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
proxmoxtypes "github.com/bpg/terraform-provider-proxmox/proxmox/types"
"github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr"
)
// Ensure the implementations satisfy the required interfaces.
@ -67,7 +67,7 @@ func (v Value) ValueStringPointer(ctx context.Context, diags *diag.Diagnostics)
}
}
return proxmoxtypes.StrPtr(strings.Join(sanitizedTags, ";"))
return ptr.Ptr(strings.Join(sanitizedTags, ";"))
}
// SetValue converts a string of tags to a tags set value.

View File

@ -137,7 +137,7 @@ func (r *vmResource) create(ctx context.Context, plan vmModel, diags *diag.Diagn
Description: plan.Description.ValueStringPointer(),
Name: plan.Name.ValueStringPointer(),
Tags: plan.Tags.ValueStringPointer(ctx, diags),
Template: proxmoxtypes.CustomBoolPtr(plan.Template.ValueBoolPointer()),
Template: proxmoxtypes.CustomBoolPtr(plan.Template.ValueBool()),
VMID: int(plan.ID.ValueInt64()),
}

1
go.mod
View File

@ -10,6 +10,7 @@ require (
github.com/google/go-cmp v0.6.0
github.com/google/go-querystring v1.1.0
github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.2.1
github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637
github.com/hashicorp/terraform-plugin-framework v1.8.0
github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1

2
go.sum
View File

@ -52,6 +52,8 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.2.1 h1:tjDxcmdb+siIqkTNoV+qRH2mjYdr2hHe5MKXbp61ziM=
github.com/gorilla/schema v1.2.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=

View File

@ -20,7 +20,7 @@ func (c *Client) aclPath() string {
}
// GetACL retrieves the access control list.
func (c *Client) GetACL(ctx context.Context) ([]*ACLGetResponseData, error) {
func (c *Client) GetACL(ctx context.Context) ([]ACLGetResponseData, error) {
resBody := &ACLGetResponseBody{}
err := c.DoRequest(ctx, http.MethodGet, c.aclPath(), nil, resBody)

View File

@ -12,7 +12,7 @@ import (
// ACLGetResponseBody contains the body from an access control list response.
type ACLGetResponseBody struct {
Data []*ACLGetResponseData `json:"data,omitempty"`
Data []ACLGetResponseData `json:"data,omitempty"`
}
// ACLGetResponseData contains the data from an access control list response.
@ -31,5 +31,6 @@ type ACLUpdateRequestBody struct {
Path string `json:"path" url:"path"`
Propagate *types.CustomBool `json:"propagate,omitempty" url:"propagate,omitempty,int"`
Roles []string `json:"roles" url:"roles,comma"`
Tokens []string `json:"tokens,omitempty" url:"tokens,omitempty,comma"`
Users []string `json:"users,omitempty" url:"users,omitempty,comma"`
}

View File

@ -0,0 +1,21 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ptr
// Ptr creates a ptr from a value to use it inline.
func Ptr[T any](val T) *T {
return &val
}
// Or will dereference a pointer and return the given value if it's nil.
func Or[T any](p *T, or T) T {
if p != nil {
return *p
}
return or
}

View File

@ -215,35 +215,6 @@ func (d CustomStorageDevice) EncodeValues(key string, v *url.Values) error {
return nil
}
// Copy returns a deep copy of the CustomStorageDevice.
func (d CustomStorageDevice) Copy() *CustomStorageDevice {
return &CustomStorageDevice{
AIO: types.CopyString(d.AIO),
Backup: d.Backup.Copy(),
BurstableReadSpeedMbps: types.CopyInt(d.BurstableReadSpeedMbps),
BurstableWriteSpeedMbps: types.CopyInt(d.BurstableWriteSpeedMbps),
Cache: types.CopyString(d.Cache),
DatastoreID: types.CopyString(d.DatastoreID),
Discard: types.CopyString(d.Discard),
Enabled: d.Enabled,
FileID: types.CopyString(d.FileID),
FileVolume: d.FileVolume,
Format: types.CopyString(d.Format),
Interface: types.CopyString(d.Interface),
IopsRead: types.CopyInt(d.IopsRead),
IopsWrite: types.CopyInt(d.IopsWrite),
IOThread: d.IOThread.Copy(),
MaxIopsRead: types.CopyInt(d.MaxIopsRead),
MaxIopsWrite: types.CopyInt(d.MaxIopsWrite),
MaxReadSpeedMbps: types.CopyInt(d.MaxReadSpeedMbps),
MaxWriteSpeedMbps: types.CopyInt(d.MaxWriteSpeedMbps),
Media: types.CopyString(d.Media),
Replicate: d.Replicate.Copy(),
Size: d.Size.Copy(),
SSD: d.SSD.Copy(),
}
}
// CustomStorageDevices handles map of QEMU storage device per disk interface.
type CustomStorageDevices map[string]*CustomStorageDevice

View File

@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr"
"github.com/bpg/terraform-provider-proxmox/proxmox/types"
)
@ -29,26 +30,26 @@ func TestCustomStorageDevice_UnmarshalJSON(t *testing.T) {
name: "simple volume",
line: `"local-lvm:vm-2041-disk-0,discard=on,ssd=1,iothread=1,size=8G,cache=writeback"`,
want: &CustomStorageDevice{
Cache: types.StrPtr("writeback"),
Discard: types.StrPtr("on"),
Cache: ptr.Ptr("writeback"),
Discard: ptr.Ptr("on"),
Enabled: true,
FileVolume: "local-lvm:vm-2041-disk-0",
IOThread: types.BoolPtr(true),
IOThread: types.CustomBoolPtr(true),
Size: ds8gig,
SSD: types.BoolPtr(true),
SSD: types.CustomBoolPtr(true),
},
},
{
name: "raw volume type",
line: `"nfs:2041/vm-2041-disk-0.raw,discard=ignore,ssd=1,iothread=1,size=8G"`,
want: &CustomStorageDevice{
Discard: types.StrPtr("ignore"),
Discard: ptr.Ptr("ignore"),
Enabled: true,
FileVolume: "nfs:2041/vm-2041-disk-0.raw",
Format: types.StrPtr("raw"),
IOThread: types.BoolPtr(true),
Format: ptr.Ptr("raw"),
IOThread: types.CustomBoolPtr(true),
Size: ds8gig,
SSD: types.BoolPtr(true),
SSD: types.CustomBoolPtr(true),
},
},
}
@ -84,21 +85,21 @@ func TestCustomStorageDevice_IsCloudInitDrive(t *testing.T) {
}, {
name: "on directory storage",
device: CustomStorageDevice{
Media: types.StrPtr("cdrom"),
Media: ptr.Ptr("cdrom"),
FileVolume: "local:131/vm-131-cloudinit.qcow2",
},
want: true,
}, {
name: "on block storage",
device: CustomStorageDevice{
Media: types.StrPtr("cdrom"),
Media: ptr.Ptr("cdrom"),
FileVolume: "local-lvm:vm-131-cloudinit",
},
want: true,
}, {
name: "wrong VM ID",
device: CustomStorageDevice{
Media: types.StrPtr("cdrom"),
Media: ptr.Ptr("cdrom"),
FileVolume: "local-lvm:vm-123-cloudinit",
},
want: false,
@ -132,13 +133,13 @@ func TestCustomStorageDevice_StorageInterface(t *testing.T) {
{
name: "virtio0",
device: CustomStorageDevice{
Interface: types.StrPtr("virtio0"),
Interface: ptr.Ptr("virtio0"),
},
want: "virtio",
}, {
name: "scsi13",
device: CustomStorageDevice{
Interface: types.StrPtr("scsi13"),
Interface: ptr.Ptr("scsi13"),
},
want: "scsi",
},
@ -174,10 +175,10 @@ func TestCustomStorageDevices_ByStorageInterface(t *testing.T) {
iface: "sata",
devices: CustomStorageDevices{
"virtio0": &CustomStorageDevice{
Interface: types.StrPtr("virtio0"),
Interface: ptr.Ptr("virtio0"),
},
"scsi13": &CustomStorageDevice{
Interface: types.StrPtr("scsi13"),
Interface: ptr.Ptr("scsi13"),
},
},
want: CustomStorageDevices{},
@ -187,21 +188,21 @@ func TestCustomStorageDevices_ByStorageInterface(t *testing.T) {
iface: "virtio",
devices: CustomStorageDevices{
"virtio0": &CustomStorageDevice{
Interface: types.StrPtr("virtio0"),
Interface: ptr.Ptr("virtio0"),
},
"scsi13": &CustomStorageDevice{
Interface: types.StrPtr("scsi13"),
Interface: ptr.Ptr("scsi13"),
},
"virtio1": &CustomStorageDevice{
Interface: types.StrPtr("virtio1"),
Interface: ptr.Ptr("virtio1"),
},
},
want: CustomStorageDevices{
"virtio0": &CustomStorageDevice{
Interface: types.StrPtr("virtio0"),
Interface: ptr.Ptr("virtio0"),
},
"virtio1": &CustomStorageDevice{
Interface: types.StrPtr("virtio1"),
Interface: ptr.Ptr("virtio1"),
},
},
},
@ -239,10 +240,10 @@ func TestCustomPCIDevice_UnmarshalJSON(t *testing.T) {
want: &CustomPCIDevice{
DeviceIDs: &[]string{"81:00.4"},
MDev: nil,
PCIExpress: types.BoolPtr(false),
ROMBAR: types.BoolPtr(true),
PCIExpress: types.CustomBoolPtr(false),
ROMBAR: types.CustomBoolPtr(true),
ROMFile: nil,
XVGA: types.BoolPtr(false),
XVGA: types.CustomBoolPtr(false),
},
},
{
@ -250,12 +251,12 @@ func TestCustomPCIDevice_UnmarshalJSON(t *testing.T) {
line: `"mapping=mappeddevice,pcie=0,rombar=1,x-vga=0"`,
want: &CustomPCIDevice{
DeviceIDs: nil,
Mapping: types.StrPtr("mappeddevice"),
Mapping: ptr.Ptr("mappeddevice"),
MDev: nil,
PCIExpress: types.BoolPtr(false),
ROMBAR: types.BoolPtr(true),
PCIExpress: types.CustomBoolPtr(false),
ROMBAR: types.CustomBoolPtr(true),
ROMFile: nil,
XVGA: types.BoolPtr(false),
XVGA: types.CustomBoolPtr(false),
},
},
}
@ -289,8 +290,8 @@ func TestCustomNUMADevice_UnmarshalJSON(t *testing.T) {
want: &CustomNUMADevice{
CPUIDs: []string{"1-2", "3-4"},
HostNodeNames: &[]string{"1-2"},
Memory: types.IntPtr(1024),
Policy: types.StrPtr("preferred"),
Memory: ptr.Ptr(1024),
Policy: ptr.Ptr("preferred"),
},
},
{
@ -298,7 +299,7 @@ func TestCustomNUMADevice_UnmarshalJSON(t *testing.T) {
line: `"cpus=1-2,memory=1024"`,
want: &CustomNUMADevice{
CPUIDs: []string{"1-2"},
Memory: types.IntPtr(1024),
Memory: ptr.Ptr(1024),
},
},
}
@ -328,15 +329,15 @@ func TestCustomUSBDevice_UnmarshalJSON(t *testing.T) {
name: "id only usb device",
line: `"host=0000:81"`,
want: &CustomUSBDevice{
HostDevice: types.StrPtr("0000:81"),
HostDevice: ptr.Ptr("0000:81"),
},
},
{
name: "usb device with more details",
line: `"host=81:00,usb3=0"`,
want: &CustomUSBDevice{
HostDevice: types.StrPtr("81:00"),
USB3: types.BoolPtr(false),
HostDevice: ptr.Ptr("81:00"),
USB3: types.CustomBoolPtr(false),
},
},
{
@ -344,8 +345,8 @@ func TestCustomUSBDevice_UnmarshalJSON(t *testing.T) {
line: `"mapping=mappeddevice,usb=0"`,
want: &CustomUSBDevice{
HostDevice: nil,
Mapping: types.StrPtr("mappeddevice"),
USB3: types.BoolPtr(false),
Mapping: ptr.Ptr("mappeddevice"),
USB3: types.CustomBoolPtr(false),
},
},
}

View File

@ -15,6 +15,8 @@ import (
"time"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr"
)
// CustomBool allows a JSON boolean value to also be an integer.
@ -38,13 +40,9 @@ type CustomPrivileges []string
// CustomTimestamp allows a JSON boolean value to also be a unix timestamp.
type CustomTimestamp time.Time
// CustomBoolPtr creates a CustomBool pointer from a boolean pointer.
func CustomBoolPtr(v *bool) *CustomBool {
if v == nil {
return nil
}
return BoolPtr(*v)
// CustomBoolPtr creates a pointer to a CustomBool.
func CustomBoolPtr(b bool) *CustomBool {
return ptr.Ptr(CustomBool(b))
}
// MarshalJSON converts a boolean to a JSON value.
@ -88,15 +86,6 @@ func (r *CustomBool) FromValue(tfValue types.Bool) {
*r = CustomBool(tfValue.ValueBool())
}
// Copy returns a copy of the boolean.
func (r *CustomBool) Copy() *CustomBool {
if r == nil {
return nil
}
return BoolPtr(bool(*r))
}
// MarshalJSON converts a boolean to a JSON value.
func (r *CustomCommaSeparatedList) MarshalJSON() ([]byte, error) {
s := strings.Join(*r, ",")

View File

@ -76,17 +76,6 @@ func (r *DiskSize) UnmarshalJSON(b []byte) error {
return nil
}
// Copy returns a deep copy of the disk size.
func (r *DiskSize) Copy() *DiskSize {
if r == nil {
return nil
}
c := *r
return &c
}
// ParseDiskSize parses a disk size string into a number of bytes.
func ParseDiskSize(size string) (DiskSize, error) {
matches := sizeRegex.FindStringSubmatch(size)

View File

@ -1,41 +0,0 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package types
// StrPtr returns a pointer to a string.
func StrPtr(s string) *string {
return &s
}
// IntPtr returns a pointer to an int.
func IntPtr(i int) *int {
return &i
}
// BoolPtr returns a pointer to a bool.
func BoolPtr(s bool) *CustomBool {
customBool := CustomBool(s)
return &customBool
}
// CopyString copies content of a string pointer.
func CopyString(s *string) *string {
if s == nil {
return nil
}
return StrPtr(*s)
}
// CopyInt copies content of an int pointer.
func CopyInt(i *int) *int {
if i == nil {
return nil
}
return IntPtr(*i)
}

View File

@ -17,6 +17,7 @@ import (
"strings"
"time"
"github.com/bpg/terraform-provider-proxmox/proxmox/helpers/ptr"
"golang.org/x/exp/maps"
"github.com/bpg/terraform-provider-proxmox/proxmoxtf/resource/vm/disk"
@ -3270,7 +3271,7 @@ func vmGetSMBIOS(d *schema.ResourceData) *vms.CustomSMBIOS {
}
if smbios.UUID == nil || *smbios.UUID == "" {
smbios.UUID = types.StrPtr(uuid.New().String())
smbios.UUID = ptr.Ptr(uuid.New().String())
}
return &smbios

View File

@ -45,5 +45,6 @@ import (
//go:generate cp ../build/docs-gen/resources/virtual_environment_haresource.md ../docs/resources/
//go:generate cp ../build/docs-gen/resources/virtual_environment_cluster_options.md ../docs/resources/
//go:generate cp ../build/docs-gen/resources/virtual_environment_download_file.md ../docs/resources/
//go:generate cp ../build/docs-gen/resources/virtual_environment_acl.md ../docs/resources/
//go:generate cp ../build/docs-gen/resources/virtual_environment_user_token.md ../docs/resources/
//go:generate cp ../build/docs-gen/resources/virtual_environment_vm2.md ../docs/resources/