From 357f7c70a701c42dda62e0adef1337d319aaed04 Mon Sep 17 00:00:00 2001 From: Sven Greb Date: Sat, 6 Jul 2024 00:48:35 +0200 Subject: [PATCH] feat(node): implement initial support to manage APT repositories (#1325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(nodes): Initial support to manage APT repositories > Summary This commit implements initial support for managing APT repositories which is (currently) limited to… - …adding "standard" repositories to allow to configure it. - toggling the activation status (enabled/disabled) of any configured repository. + !WARNING! + Note that deleting or modifying a repository in any other way is + (sadly) not possible (yet?)! + The limited functionality is due to the (current) capabilities of + the Proxmox VE APT repository API [1] itself. >> Why are there two resources for one API entity? Even though an APT repository should be seen as a single API entity, it was required to implement standard repositories as dedicated `proxmox_virtual_environment_apt_standard_repository`. This is because standard repositories must be configured (added) first to the default source list files because their activation status can be toggled. This is handled by the HTTP `PUT` request, but the modifying request is `POST` which would require two calls within the same Terraform execution cycle. I tried to implement it in a single resource and it worked out mostly after some handling some edges cases, but in the end there were still too many situations an edge cases where it might break due to Terraform state drifts between states. In the end the dedicated resources are way cleaner and easier to use without no complexity and conditional attribute juggling for practitioners. >> Other "specialties" Unfortunately the Proxmox VE API responses to HTTP `GET` requests with four larger arrays which are, more or less, kind of connected to each other, but they also somehow stand on their own. This means that there is a `files` array that contains the `repositories` again which again contains all repositories with their metadata of every source file. On the other hand available standard repositories are listed in the `standard-repos` array, but their activation status is only stored when they have already been added through a `PUT` request. The `infos` array is more less useless. So in order to get the required data and store them in the state the `importFromAPI` methods of the models must loop through all the deep-nested arrays and act based on specific attributes like a matching file path, comparing it to the activation status and so on. In the end the implementation is really stable after testing it with all possible conditions and state combinations. @bpg if you'd like me to create a small data logic flow chart to make it easier to understand some parts of the code let me know. I can make my local notes "shareable" which I created to not loose track of the logic. >> What is the way to manage the activation status of a "standard" repository? Because the two resources are modular and scoped they can be simply combined to manage an APT "standard" repository, e.g. toggling its activation status. The following examples are also included in the documentations. ```hcl // This resource ensure that the "no-subscription" standard repository // is added to the source list. // It represents the `PUT` API request. resource "proxmox_virtual_environment_apt_standard_repository" "example" { handle = "no-subscription" node = "pve" } // This resource allows to actually modify the activation status of the // standard repository as it represents the `POST`. // Using the values from the dedicated standard repository resource // makes sure that Terraform correctly resolves dependency order. resource "proxmox_virtual_environment_apt_repository" "example" { enabled = true file_path = proxmox_virtual_environment_apt_standard_repository.example.file_path index = proxmox_virtual_environment_apt_standard_repository.example.index node = proxmox_virtual_environment_apt_standard_repository.example.node } ``` [1]: https://pve.proxmox.com/pve-docs/api-viewer/#/nodes/{node}/apt/repositories --------- Signed-off-by: Sven Greb Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> --- .golangci.yml | 4 + .../virtual_environment_apt_repository.md | 46 ++ ...ual_environment_apt_standard_repository.md | 42 ++ .../virtual_environment_apt_repository.md | 57 +++ ...ual_environment_apt_standard_repository.md | 56 +++ .../data-source.tf | 9 + .../data-source.tf | 8 + .../import.sh | 4 + .../resource.tf | 6 + .../import.sh | 4 + .../resource.tf | 11 + fwprovider/nodes/apt/datasource_repo.go | 166 +++++++ .../nodes/apt/datasource_standard_repo.go | 142 ++++++ fwprovider/nodes/apt/models.go | 281 +++++++++++ fwprovider/nodes/apt/repo_test.go | 465 ++++++++++++++++++ fwprovider/nodes/apt/resource_repo.go | 353 +++++++++++++ .../nodes/apt/resource_standard_repo.go | 286 +++++++++++ fwprovider/provider.go | 5 + fwprovider/types/nodes/apt/errors.go | 21 + .../types/nodes/apt/standard_repo_handle.go | 187 +++++++ .../nodes/apt/standard_repo_handle_test.go | 123 +++++ fwprovider/validators/strings.go | 39 ++ proxmox/nodes/apt/client.go | 29 ++ .../nodes/apt/repositories/repositories.go | 72 +++ proxmox/nodes/apt/repositories/types.go | 110 +++++ proxmox/nodes/client.go | 8 + proxmox/nodes/network.go | 2 +- .../apt/repositories/ceph_version_name.go | 105 ++++ .../types/nodes/apt/repositories/errors.go | 38 ++ .../repositories/standard_repo_file_path.go | 22 + .../repositories/standard_repo_handle_kind.go | 109 ++++ tools/tools.go | 4 + 32 files changed, 2813 insertions(+), 1 deletion(-) create mode 100644 docs/data-sources/virtual_environment_apt_repository.md create mode 100644 docs/data-sources/virtual_environment_apt_standard_repository.md create mode 100644 docs/resources/virtual_environment_apt_repository.md create mode 100644 docs/resources/virtual_environment_apt_standard_repository.md create mode 100644 examples/data-sources/proxmox_virtual_environment_apt_repository/data-source.tf create mode 100644 examples/data-sources/proxmox_virtual_environment_apt_standard_repository/data-source.tf create mode 100644 examples/resources/proxmox_virtual_environment_apt_repository/import.sh create mode 100644 examples/resources/proxmox_virtual_environment_apt_repository/resource.tf create mode 100644 examples/resources/proxmox_virtual_environment_apt_standard_repository/import.sh create mode 100644 examples/resources/proxmox_virtual_environment_apt_standard_repository/resource.tf create mode 100644 fwprovider/nodes/apt/datasource_repo.go create mode 100644 fwprovider/nodes/apt/datasource_standard_repo.go create mode 100644 fwprovider/nodes/apt/models.go create mode 100644 fwprovider/nodes/apt/repo_test.go create mode 100644 fwprovider/nodes/apt/resource_repo.go create mode 100644 fwprovider/nodes/apt/resource_standard_repo.go create mode 100644 fwprovider/types/nodes/apt/errors.go create mode 100644 fwprovider/types/nodes/apt/standard_repo_handle.go create mode 100644 fwprovider/types/nodes/apt/standard_repo_handle_test.go create mode 100644 fwprovider/validators/strings.go create mode 100644 proxmox/nodes/apt/client.go create mode 100644 proxmox/nodes/apt/repositories/repositories.go create mode 100644 proxmox/nodes/apt/repositories/types.go create mode 100644 proxmox/types/nodes/apt/repositories/ceph_version_name.go create mode 100644 proxmox/types/nodes/apt/repositories/errors.go create mode 100644 proxmox/types/nodes/apt/repositories/standard_repo_file_path.go create mode 100644 proxmox/types/nodes/apt/repositories/standard_repo_handle_kind.go diff --git a/.golangci.yml b/.golangci.yml index 36e6a07f..37df2202 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -45,6 +45,10 @@ linters-settings: statements: 60 errcheck: check-blank: true + wrapcheck: + ignorePackageGlobs: + # Prevent false-positive matches for errors from packages of the own module. + - github.com/bpg/terraform-provider-proxmox/* linters: enable-all: true disable: diff --git a/docs/data-sources/virtual_environment_apt_repository.md b/docs/data-sources/virtual_environment_apt_repository.md new file mode 100644 index 00000000..a191f285 --- /dev/null +++ b/docs/data-sources/virtual_environment_apt_repository.md @@ -0,0 +1,46 @@ +--- +layout: page +title: proxmox_virtual_environment_apt_repository +parent: Data Sources +subcategory: Virtual Environment +description: |- + Retrieves an APT repository from a Proxmox VE cluster. +--- + +# Data Source: proxmox_virtual_environment_apt_repository + +Retrieves an APT repository from a Proxmox VE cluster. + +## Example Usage + +```terraform +data "proxmox_virtual_environment_apt_repository" "example" { + file_path = "/etc/apt/sources.list" + index = 0 + node = "pve" +} + +output "proxmox_virtual_environment_apt_repository" { + value = data.proxmox_virtual_environment_apt_repository.example +} +``` + + +## Schema + +### Required + +- `file_path` (String) The absolute path of the source list file that contains this repository. +- `index` (Number) The index within the defining source list file. +- `node` (String) The name of the target Proxmox VE node. + +### Read-Only + +- `comment` (String) The associated comment. +- `components` (List of String) The list of components. +- `enabled` (Boolean) Indicates the activation status. +- `file_type` (String) The format of the defining source list file. +- `id` (String) The unique identifier of this APT repository data source. +- `package_types` (List of String) The list of package types. +- `suites` (List of String) The list of package distributions. +- `uris` (List of String) The list of repository URIs. diff --git a/docs/data-sources/virtual_environment_apt_standard_repository.md b/docs/data-sources/virtual_environment_apt_standard_repository.md new file mode 100644 index 00000000..910f7f9c --- /dev/null +++ b/docs/data-sources/virtual_environment_apt_standard_repository.md @@ -0,0 +1,42 @@ +--- +layout: page +title: proxmox_virtual_environment_apt_standard_repository +parent: Data Sources +subcategory: Virtual Environment +description: |- + Retrieves an APT standard repository from a Proxmox VE cluster. +--- + +# Data Source: proxmox_virtual_environment_apt_standard_repository + +Retrieves an APT standard repository from a Proxmox VE cluster. + +## Example Usage + +```terraform +data "proxmox_virtual_environment_apt_standard_repository" "example" { + handle = "no-subscription" + node = "pve" +} + +output "proxmox_virtual_environment_apt_standard_repository" { + value = data.proxmox_virtual_environment_apt_standard_repository.example +} +``` + + +## Schema + +### Required + +- `handle` (String) The handle of the APT standard repository. +- `node` (String) The name of the target Proxmox VE node. + +### Read-Only + +- `description` (String) The description of the APT standard repository. +- `file_path` (String) The absolute path of the source list file that contains this standard repository. +- `id` (String) The unique identifier of this APT standard repository data source. +- `index` (Number) The index within the defining source list file. +- `name` (String) The name of the APT standard repository. +- `status` (Number) Indicates the activation status. diff --git a/docs/resources/virtual_environment_apt_repository.md b/docs/resources/virtual_environment_apt_repository.md new file mode 100644 index 00000000..a2f79179 --- /dev/null +++ b/docs/resources/virtual_environment_apt_repository.md @@ -0,0 +1,57 @@ +--- +layout: page +title: proxmox_virtual_environment_apt_repository +parent: Resources +subcategory: Virtual Environment +description: |- + Manages an APT repository of a Proxmox VE node. +--- + +# Resource: proxmox_virtual_environment_apt_repository + +Manages an APT repository of a Proxmox VE node. + +## Example Usage + +```terraform +resource "proxmox_virtual_environment_apt_repository" "example" { + enabled = true + file_path = "/etc/apt/sources.list" + index = 0 + node = "pve" +} +``` + + +## Schema + +### Required + +- `file_path` (String) The absolute path of the source list file that contains this repository. +- `index` (Number) The index within the defining source list file. +- `node` (String) The name of the target Proxmox VE node. + +### Optional + +- `enabled` (Boolean) Indicates the activation status. + +### Read-Only + +- `comment` (String) The associated comment. +- `components` (List of String) The list of components. +- `file_type` (String) The format of the defining source list file. +- `id` (String) The unique identifier of this APT repository resource. +- `package_types` (List of String) The list of package types. +- `suites` (List of String) The list of package distributions. +- `uris` (List of String) The list of repository URIs. + +## Import + +Import is supported using the following syntax: + +```shell +#!/usr/bin/env sh +# An APT repository can be imported using a comma-separated list consisting of the name of the Proxmox VE node, +# the absolute source list file path, and the index in the exact same order, e.g.: +terraform import proxmox_virtual_environment_apt_repository.example pve,/etc/apt/sources.list,0 +``` diff --git a/docs/resources/virtual_environment_apt_standard_repository.md b/docs/resources/virtual_environment_apt_standard_repository.md new file mode 100644 index 00000000..40ca4403 --- /dev/null +++ b/docs/resources/virtual_environment_apt_standard_repository.md @@ -0,0 +1,56 @@ +--- +layout: page +title: proxmox_virtual_environment_apt_standard_repository +parent: Resources +subcategory: Virtual Environment +description: |- + Manages an APT standard repository of a Proxmox VE node. +--- + +# Resource: proxmox_virtual_environment_apt_standard_repository + +Manages an APT standard repository of a Proxmox VE node. + +## Example Usage + +```terraform +resource "proxmox_virtual_environment_apt_standard_repository" "example" { + handle = "no-subscription" + node = "pve" +} + +resource "proxmox_virtual_environment_apt_repository" "example" { + enabled = true + file_path = proxmox_virtual_environment_apt_standard_repository.example.file_path + index = proxmox_virtual_environment_apt_standard_repository.example.index + node = proxmox_virtual_environment_apt_standard_repository.example.node +} +``` + + +## Schema + +### Required + +- `handle` (String) The handle of the APT standard repository. Must be `ceph-quincy-enterprise` | `ceph-quincy-no-subscription` | `ceph-quincy-test` | `ceph-reef-enterprise` | `ceph-reef-no-subscription` | `ceph-reef-test` | `enterprise` | `no-subscription` | `test`. +- `node` (String) The name of the target Proxmox VE node. + +### Read-Only + +- `description` (String) The description of the APT standard repository. +- `file_path` (String) The absolute path of the source list file that contains this standard repository. +- `id` (String) The unique identifier of this APT standard repository resource. +- `index` (Number) The index within the defining source list file. +- `name` (String) The name of the APT standard repository. +- `status` (Number) Indicates the activation status. + +## Import + +Import is supported using the following syntax: + +```shell +#!/usr/bin/env sh +# An APT standard repository can be imported using a comma-separated list consisting of the name of the Proxmox VE node, +# and the standard repository handle in the exact same order, e.g.: +terraform import proxmox_virtual_environment_apt_standard_repository.example pve,no-subscription +``` diff --git a/examples/data-sources/proxmox_virtual_environment_apt_repository/data-source.tf b/examples/data-sources/proxmox_virtual_environment_apt_repository/data-source.tf new file mode 100644 index 00000000..6c3f5379 --- /dev/null +++ b/examples/data-sources/proxmox_virtual_environment_apt_repository/data-source.tf @@ -0,0 +1,9 @@ +data "proxmox_virtual_environment_apt_repository" "example" { + file_path = "/etc/apt/sources.list" + index = 0 + node = "pve" +} + +output "proxmox_virtual_environment_apt_repository" { + value = data.proxmox_virtual_environment_apt_repository.example +} diff --git a/examples/data-sources/proxmox_virtual_environment_apt_standard_repository/data-source.tf b/examples/data-sources/proxmox_virtual_environment_apt_standard_repository/data-source.tf new file mode 100644 index 00000000..c5ea2850 --- /dev/null +++ b/examples/data-sources/proxmox_virtual_environment_apt_standard_repository/data-source.tf @@ -0,0 +1,8 @@ +data "proxmox_virtual_environment_apt_standard_repository" "example" { + handle = "no-subscription" + node = "pve" +} + +output "proxmox_virtual_environment_apt_standard_repository" { + value = data.proxmox_virtual_environment_apt_standard_repository.example +} diff --git a/examples/resources/proxmox_virtual_environment_apt_repository/import.sh b/examples/resources/proxmox_virtual_environment_apt_repository/import.sh new file mode 100644 index 00000000..007babf9 --- /dev/null +++ b/examples/resources/proxmox_virtual_environment_apt_repository/import.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +# An APT repository can be imported using a comma-separated list consisting of the name of the Proxmox VE node, +# the absolute source list file path, and the index in the exact same order, e.g.: +terraform import proxmox_virtual_environment_apt_repository.example pve,/etc/apt/sources.list,0 diff --git a/examples/resources/proxmox_virtual_environment_apt_repository/resource.tf b/examples/resources/proxmox_virtual_environment_apt_repository/resource.tf new file mode 100644 index 00000000..cedff9f1 --- /dev/null +++ b/examples/resources/proxmox_virtual_environment_apt_repository/resource.tf @@ -0,0 +1,6 @@ +resource "proxmox_virtual_environment_apt_repository" "example" { + enabled = true + file_path = "/etc/apt/sources.list" + index = 0 + node = "pve" +} diff --git a/examples/resources/proxmox_virtual_environment_apt_standard_repository/import.sh b/examples/resources/proxmox_virtual_environment_apt_standard_repository/import.sh new file mode 100644 index 00000000..c1b53310 --- /dev/null +++ b/examples/resources/proxmox_virtual_environment_apt_standard_repository/import.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +# An APT standard repository can be imported using a comma-separated list consisting of the name of the Proxmox VE node, +# and the standard repository handle in the exact same order, e.g.: +terraform import proxmox_virtual_environment_apt_standard_repository.example pve,no-subscription diff --git a/examples/resources/proxmox_virtual_environment_apt_standard_repository/resource.tf b/examples/resources/proxmox_virtual_environment_apt_standard_repository/resource.tf new file mode 100644 index 00000000..4546f858 --- /dev/null +++ b/examples/resources/proxmox_virtual_environment_apt_standard_repository/resource.tf @@ -0,0 +1,11 @@ +resource "proxmox_virtual_environment_apt_standard_repository" "example" { + handle = "no-subscription" + node = "pve" +} + +resource "proxmox_virtual_environment_apt_repository" "example" { + enabled = true + file_path = proxmox_virtual_environment_apt_standard_repository.example.file_path + index = proxmox_virtual_environment_apt_standard_repository.example.index + node = proxmox_virtual_environment_apt_standard_repository.example.node +} diff --git a/fwprovider/nodes/apt/datasource_repo.go b/fwprovider/nodes/apt/datasource_repo.go new file mode 100644 index 00000000..a119bdfd --- /dev/null +++ b/fwprovider/nodes/apt/datasource_repo.go @@ -0,0 +1,166 @@ +/* + * 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 apt + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/bpg/terraform-provider-proxmox/fwprovider/attribute" + "github.com/bpg/terraform-provider-proxmox/fwprovider/validators" + "github.com/bpg/terraform-provider-proxmox/proxmox" +) + +// Ensure the implementation satisfies the required interfaces. +var ( + _ datasource.DataSource = &dataSourceRepo{} + _ datasource.DataSourceWithConfigure = &dataSourceRepo{} +) + +// dataSourceRepo is the data source implementation for an APT repository. +type dataSourceRepo struct { + // client is the Proxmox VE API client. + client proxmox.Client +} + +// Configure adds the provider-configured client to the data source. +func (d *dataSourceRepo) Configure( + _ context.Context, + req datasource.ConfigureRequest, + resp *datasource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(proxmox.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *proxmox.Client, got: %T", req.ProviderData), + ) + + return + } + + d.client = client +} + +// Metadata returns the data source type name. +func (d *dataSourceRepo) Metadata( + _ context.Context, + req datasource.MetadataRequest, + resp *datasource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_apt_repository" +} + +// Read fetches the specified APT repository from the Proxmox VE API. +func (d *dataSourceRepo) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var rp modelRepo + + resp.Diagnostics.Append(req.Config.Get(ctx, &rp)...) + + if resp.Diagnostics.HasError() { + return + } + + data, err := d.client.Node(rp.Node.ValueString()).APT().Repositories().Get(ctx) + if err != nil { + resp.Diagnostics.AddError("Could not read APT repository", err.Error()) + + return + } + + resp.Diagnostics.Append(rp.importFromAPI(ctx, data)...) + + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &rp)...) +} + +// Schema defines the schema for the APT repository. +func (d *dataSourceRepo) Schema( + _ context.Context, + _ datasource.SchemaRequest, + resp *datasource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + Description: "Retrieves an APT repository from a Proxmox VE cluster.", + Attributes: map[string]schema.Attribute{ + SchemaAttrNameComment: schema.StringAttribute{ + Computed: true, + Description: "The associated comment.", + }, + SchemaAttrNameComponents: schema.ListAttribute{ + Computed: true, + Description: "The list of components.", + ElementType: types.StringType, + }, + SchemaAttrNameEnabled: schema.BoolAttribute{ + Computed: true, + Description: "Indicates the activation status.", + }, + SchemaAttrNameFilePath: schema.StringAttribute{ + Description: "The absolute path of the source list file that contains this repository.", + Required: true, + Validators: []validator.String{ + validators.NonEmptyString(), + }, + }, + SchemaAttrNameFileType: schema.StringAttribute{ + Computed: true, + Description: "The format of the defining source list file.", + }, + SchemaAttrNameIndex: schema.Int64Attribute{ + Description: "The index within the defining source list file.", + Required: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + SchemaAttrNameNode: schema.StringAttribute{ + Description: "The name of the target Proxmox VE node.", + Required: true, + Validators: []validator.String{ + validators.NonEmptyString(), + }, + }, + SchemaAttrNamePackageTypes: schema.ListAttribute{ + Computed: true, + Description: "The list of package types.", + ElementType: types.StringType, + }, + SchemaAttrNameSuites: schema.ListAttribute{ + Computed: true, + Description: "The list of package distributions.", + ElementType: types.StringType, + }, + SchemaAttrNameTerraformID: attribute.ID("The unique identifier of this APT repository data source."), + SchemaAttrNameURIs: schema.ListAttribute{ + Computed: true, + Description: "The list of repository URIs.", + ElementType: types.StringType, + }, + }, + } +} + +// NewDataSourceRepo returns a new data source for an APT repository. +// This is a helper function to simplify the provider implementation. +func NewDataSourceRepo() datasource.DataSource { + return &dataSourceRepo{} +} diff --git a/fwprovider/nodes/apt/datasource_standard_repo.go b/fwprovider/nodes/apt/datasource_standard_repo.go new file mode 100644 index 00000000..7bfdf97e --- /dev/null +++ b/fwprovider/nodes/apt/datasource_standard_repo.go @@ -0,0 +1,142 @@ +/* + * 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 apt + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/bpg/terraform-provider-proxmox/fwprovider/attribute" + customtypes "github.com/bpg/terraform-provider-proxmox/fwprovider/types/nodes/apt" + "github.com/bpg/terraform-provider-proxmox/fwprovider/validators" + "github.com/bpg/terraform-provider-proxmox/proxmox" +) + +// Ensure the implementation satisfies the required interfaces. +var ( + _ datasource.DataSource = &dataSourceStandardRepo{} + _ datasource.DataSourceWithConfigure = &dataSourceStandardRepo{} +) + +// dataSourceStandardRepo is the data source implementation for an APT standard repository. +type dataSourceStandardRepo struct { + // client is the Proxmox VE API client. + client proxmox.Client +} + +// Configure adds the provider-configured client to the data source. +func (d *dataSourceStandardRepo) Configure( + _ context.Context, + req datasource.ConfigureRequest, + resp *datasource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(proxmox.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *proxmox.Client, got: %T", req.ProviderData), + ) + + return + } + + d.client = client +} + +// Metadata returns the data source type name. +func (d *dataSourceStandardRepo) Metadata( + _ context.Context, + req datasource.MetadataRequest, + resp *datasource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_apt_standard_repository" +} + +// Read fetches the specified APT standard repository from the Proxmox VE API. +func (d *dataSourceStandardRepo) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var srp modelStandardRepo + + resp.Diagnostics.Append(req.Config.Get(ctx, &srp)...) + + if resp.Diagnostics.HasError() { + return + } + + data, err := d.client.Node(srp.Node.ValueString()).APT().Repositories().Get(ctx) + if err != nil { + resp.Diagnostics.AddError("Could not read APT standard repository", err.Error()) + + return + } + + srp.importFromAPI(ctx, data) + + resp.Diagnostics.Append(resp.State.Set(ctx, &srp)...) +} + +// Schema defines the schema for the APT standard repository. +func (d *dataSourceStandardRepo) Schema( + _ context.Context, + _ datasource.SchemaRequest, + resp *datasource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + Description: "Retrieves an APT standard repository from a Proxmox VE cluster.", + Attributes: map[string]schema.Attribute{ + SchemaAttrNameStandardDescription: schema.StringAttribute{ + Computed: true, + Description: "The description of the APT standard repository.", + }, + SchemaAttrNameFilePath: schema.StringAttribute{ + Computed: true, + Description: "The absolute path of the source list file that contains this standard repository.", + }, + SchemaAttrNameStandardHandle: schema.StringAttribute{ + CustomType: customtypes.StandardRepoHandleType{}, + Description: "The handle of the APT standard repository.", + Required: true, + }, + SchemaAttrNameIndex: schema.Int64Attribute{ + Computed: true, + Description: "The index within the defining source list file.", + }, + SchemaAttrNameStandardName: schema.StringAttribute{ + Computed: true, + Description: "The name of the APT standard repository.", + }, + SchemaAttrNameNode: schema.StringAttribute{ + Description: "The name of the target Proxmox VE node.", + Required: true, + Validators: []validator.String{ + validators.NonEmptyString(), + }, + }, + SchemaAttrNameStandardStatus: schema.Int64Attribute{ + Computed: true, + Description: "Indicates the activation status.", + }, + SchemaAttrNameTerraformID: attribute.ID( + "The unique identifier of this APT standard repository data source.", + ), + }, + } +} + +// NewDataSourceStandardRepo returns a new data source for an APT standard repository. +// This is a helper function to simplify the provider implementation. +func NewDataSourceStandardRepo() datasource.DataSource { + return &dataSourceStandardRepo{} +} diff --git a/fwprovider/nodes/apt/models.go b/fwprovider/nodes/apt/models.go new file mode 100644 index 00000000..15895508 --- /dev/null +++ b/fwprovider/nodes/apt/models.go @@ -0,0 +1,281 @@ +/* + * 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 apt + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + + customtypes "github.com/bpg/terraform-provider-proxmox/fwprovider/types/nodes/apt" + api "github.com/bpg/terraform-provider-proxmox/proxmox/nodes/apt/repositories" +) + +// Note that most constants are exported to allow the usage in (acceptance) tests. +const ( + // SchemaAttrNameComment is the name of the APT repository schema attribute for the associated comment. + SchemaAttrNameComment = "comment" + + // SchemaAttrNameComponents is the name of the APT repository schema attribute for the list of components. + SchemaAttrNameComponents = "components" + + // SchemaAttrNameEnabled is the name of the APT repository schema attribute that indicates the activation status. + SchemaAttrNameEnabled = "enabled" + + // SchemaAttrNameFilePath is the name of the APT repository schema attribute for the path of the defining source list + // file. + SchemaAttrNameFilePath = "file_path" + + // SchemaAttrNameFileType is the name of the APT repository schema attribute for the format of the defining source + // list file. + SchemaAttrNameFileType = "file_type" + + // SchemaAttrNameIndex is the name of the APT repository schema attribute for the index within the defining source + // list file. + SchemaAttrNameIndex = "index" + + // SchemaAttrNameNode is the name of the APT repository schema attribute for the name of the Proxmox VE node. + SchemaAttrNameNode = "node" + + // SchemaAttrNamePackageTypes is the name of the APT repository schema attribute for the list of package types. + SchemaAttrNamePackageTypes = "package_types" + + // SchemaAttrNameStandardDescription is the name of the APT repository schema attribute for the description. + SchemaAttrNameStandardDescription = "description" + + // SchemaAttrNameStandardHandle is the name of the APT repository schema attribute for the standard repository + // handle. + SchemaAttrNameStandardHandle = "handle" + + // SchemaAttrNameStandardName is the name of the APT repository schema attribute for the human-readable name. + SchemaAttrNameStandardName = "name" + + // SchemaAttrNameStandardStatus is the name of the APT standard repository schema attribute that indicates the + // configuration and activation status. + SchemaAttrNameStandardStatus = "status" + + // SchemaAttrNameSuites is the name of the APT repository schema attribute for the list of package distributions. + SchemaAttrNameSuites = "suites" + + // SchemaAttrNameTerraformID is the name of the APT repository schema attribute for the Terraform ID. + SchemaAttrNameTerraformID = "id" + + // SchemaAttrNameURIs is the name of the APT repository schema attribute for the list of repository URIs. + SchemaAttrNameURIs = "uris" +) + +// RepoIDCharReplaceRegEx is a regular expression to replace characters in a Terraform resource/data source ID. +// The "^" at the beginning of the character group selects all characters not matching the group. +var RepoIDCharReplaceRegEx = regexp.MustCompile(`([^a-zA-Z1-9_])`) + +// modelRepo maps the schema data for an APT repository from a parsed source list file. +type modelRepo struct { + // Comment is the comment of the APT repository. + Comment types.String `tfsdk:"comment"` + + // Components is the list of repository components. + Components types.List `tfsdk:"components"` + + // Enabled indicates whether the APT repository is enabled. + Enabled types.Bool `tfsdk:"enabled"` + + // FilePath is the path of the source list file that contains the APT repository. + FilePath types.String `tfsdk:"file_path"` + + // FileType is the format of the packages. + FileType types.String `tfsdk:"file_type"` + + // ID is the Terraform identifier of the APT repository. + ID types.String `tfsdk:"id"` + + // Index is the index of the APT repository within the defining source list. + Index types.Int64 `tfsdk:"index"` + + // Node is the name of the Proxmox VE node for the APT repository. + Node types.String `tfsdk:"node"` + + // PackageTypes is the list of package types. + PackageTypes types.List `tfsdk:"package_types"` + + // Suites is the list of package distributions. + Suites types.List `tfsdk:"suites"` + + // URIs is the list of repository URIs. + URIs types.List `tfsdk:"uris"` +} + +// modelStandardRepo maps the schema data for an APT standard repository. +type modelStandardRepo struct { + // Description is the description of the APT standard repository. + Description types.String `tfsdk:"description"` + + // FilePath is the path of the source list file that contains the APT standard repository. + FilePath types.String `tfsdk:"file_path"` + + // ID is the Terraform identifier of the APT standard repository. + ID types.String `tfsdk:"id"` + + // Index is the index of the APT standard repository within the defining source list file. + Index types.Int64 `tfsdk:"index"` + + // Handle is the handle of the APT standard repository. + Handle customtypes.StandardRepoHandleValue `tfsdk:"handle"` + + // Name is the name of the APT standard repository. + Name types.String `tfsdk:"name"` + + // Node is the name of the Proxmox VE node for the APT standard repository. + Node types.String `tfsdk:"node"` + + // Status is the configuration and activation status of the APT standard repository. + Status types.Int64 `tfsdk:"status"` +} + +// importFromAPI imports the contents of an APT repository model from the Proxmox VE API's response data. +func (rp *modelRepo) importFromAPI(ctx context.Context, data *api.GetResponseData) diag.Diagnostics { + var diags diag.Diagnostics + + // We can only ensure a unique ID by using the name of the Proxmox VE node and the absolute file path because custom + // source list files can be loaded by Proxmox VE from every path on a node. + rp.ID = types.StringValue( + fmt.Sprintf( + "%s_%s_%s_%d", + ResourceRepoIDPrefix, + strings.ToLower(rp.Node.ValueString()), + strings.ToLower(RepoIDCharReplaceRegEx.ReplaceAllString(strings.TrimPrefix(rp.FilePath.ValueString(), "/"), "_")), + rp.Index.ValueInt64(), + ), + ) + + // We must ensure that the type definitions for lists and other attributes are set since Terraform must know these + // during the planning phase. This is important when the resource was imported where only the ID is known. + rp.Comment = types.StringNull() + rp.Enabled = types.BoolNull() + rp.FileType = types.StringNull() + rp.Components = types.ListNull(types.StringType) + rp.PackageTypes = types.ListNull(types.StringType) + rp.Suites = types.ListNull(types.StringType) + rp.URIs = types.ListNull(types.StringType) + + // Iterate through all repository files… + for _, repoFile := range data.Files { + // …and the defined repositories when the file path matches. + if repoFile.Path == rp.FilePath.ValueString() { + // Handle situations where an APT repository might have been removed manually which is currently the only way to + // solve this with the capabilities of the Proxmox VE API. + if int64(len(repoFile.Repositories)) > rp.Index.ValueInt64() { + repo := repoFile.Repositories[rp.Index.ValueInt64()] + + // Strip the unnecessary new line control character (\n) from the end of the comment that is, for whatever + // reason, returned this way by the Proxmox VE API. + if repo.Comment != nil { + rp.Comment = types.StringValue(strings.TrimSuffix(*repo.Comment, "\n")) + } + + rp.Enabled = repo.Enabled.ToValue() + rp.FileType = types.StringValue(repo.FileType) + + components, convDiags := types.ListValueFrom(ctx, types.StringType, repo.Components) + if convDiags.HasError() { + diags.AddError("Terraform list value conversion", "Convert list of APT repository components") + } else { + rp.Components = components + } + + pkgTypes, convDiags := types.ListValueFrom(ctx, types.StringType, repo.PackageTypes) + if convDiags.HasError() { + diags.AddError("Terraform list value conversion", "Convert list of APT repository package types") + } else { + rp.PackageTypes = pkgTypes + } + + suites, convDiags := types.ListValueFrom(ctx, types.StringType, repo.Suites) + if convDiags.HasError() { + diags.AddError("Terraform list value conversion", "Convert list of APT repository suites") + } else { + rp.Suites = suites + } + + uris, convDiags := types.ListValueFrom(ctx, types.StringType, repo.URIs) + if convDiags.HasError() { + diags.AddError("Terraform list value conversion", "Convert list of APT repository URIs") + } else { + rp.URIs = uris + } + } + } + } + + return diags +} + +// importFromAPI imports the contents of an APT standard repository from the Proxmox VE API's response data. +func (srp *modelStandardRepo) importFromAPI(_ context.Context, data *api.GetResponseData) { + for _, repo := range data.StandardRepos { + if repo.Handle == srp.Handle.ValueString() { + srp.Description = types.StringPointerValue(repo.Description) + // We can only ensure a unique ID by using the name of the Proxmox VE node in combination with the unique standard + // handle. + srp.ID = types.StringValue( + fmt.Sprintf( + "%s_%s_%s", + ResourceStandardRepoIDPrefix, + strings.ToLower(srp.Node.ValueString()), + RepoIDCharReplaceRegEx.ReplaceAllString(srp.Handle.ValueString(), "_"), + ), + ) + + srp.Name = types.StringValue(repo.Name) + srp.Status = types.Int64PointerValue(repo.Status) + } + } + + // Set the index… + srp.setIndex(data) + // … and then the file path when the index is valid… + if !srp.Index.IsNull() { + // …by iterating through all repository files… + for _, repoFile := range data.Files { + // …and get the repository when the file path matches. + if srp.Handle.IsSupportedFilePath(repoFile.Path) { + srp.FilePath = types.StringValue(repoFile.Path) + } + } + } +} + +// setIndex sets the index of the APT standard repository derived from the defining source list file. +func (srp *modelStandardRepo) setIndex(data *api.GetResponseData) { + for _, file := range data.Files { + for idx, repo := range file.Repositories { + if slices.Contains(repo.Components, srp.Handle.ComponentName()) { + // Return early for non-Ceph repositories… + if !srp.Handle.IsCephHandle() { + srp.Index = types.Int64Value(int64(idx)) + + return + } + + // …and find the index for Ceph repositories based on the version name within the list of URIs. + for _, uri := range repo.URIs { + if strings.Contains(uri, srp.Handle.CephVersionName().String()) { + srp.Index = types.Int64Value(int64(idx)) + + return + } + } + } + } + } + + srp.Index = types.Int64Null() +} diff --git a/fwprovider/nodes/apt/repo_test.go b/fwprovider/nodes/apt/repo_test.go new file mode 100644 index 00000000..4b7438cf --- /dev/null +++ b/fwprovider/nodes/apt/repo_test.go @@ -0,0 +1,465 @@ +/* + * 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 apt_test + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "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/bpg/terraform-provider-proxmox/fwprovider/nodes/apt" + "github.com/bpg/terraform-provider-proxmox/fwprovider/test" + apitypes "github.com/bpg/terraform-provider-proxmox/proxmox/types/nodes/apt/repositories" +) + +// Note that some "hard-coded" values must be used because of the way how the Proxmox VE API for APT repositories works. +const ( + testAccResourceRepoSelector = "proxmox_virtual_environment_" + apt.ResourceRepoIDPrefix + ".test" + + // By default, this should be the main Debian package repository on any (new) Proxmox VE node. + testAccResourceRepoIndex = 0 + + testAccResourceStandardRepoSelector = "proxmox_virtual_environment_" + apt.ResourceStandardRepoIDPrefix + ".test" + + // Use an APT standard repository handle that is not enabled by default on any new Proxmox VE node. + testAccResourceStandardRepoHandle = "no-subscription" +) + +func testAccRepoInit(t *testing.T) *test.Environment { + t.Helper() + t.Parallel() + + return test.InitEnvironment(t) +} + +func TestAccDataSourceRepo(t *testing.T) { + te := testAccRepoInit(t) + + tests := []struct { + name string + steps []resource.TestStep + }{ + { + "read APT repository attributes", + []resource.TestStep{ + { + Config: fmt.Sprintf( + ` + data %q %q { + %s = %q # file_path + %s = %d # index + %s = %q # node + } + `, + strings.Split(testAccResourceRepoSelector, ".")[0], + strings.Split(testAccResourceRepoSelector, ".")[1], + // To ensure stable acceptance tests we must use one of the Proxmox VE default source lists that always + // exists on any (new) Proxmox VE node. + apt.SchemaAttrNameFilePath, apitypes.StandardRepoFilePathMain, + apt.SchemaAttrNameIndex, testAccResourceRepoIndex, + apt.SchemaAttrNameNode, te.NodeName, + ), + // The provided attributes and computed attributes should be set. + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr( + fmt.Sprintf("data.%s", testAccResourceRepoSelector), + apt.SchemaAttrNameComment, + // Expect any value or an empty string. + regexp.MustCompile(`(.*|^$)`), + ), + resource.TestCheckResourceAttr( + fmt.Sprintf("data.%s", testAccResourceRepoSelector), + apt.SchemaAttrNameTerraformID, + fmt.Sprintf( + "%s_%s_%s_%d", + apt.ResourceRepoIDPrefix, + strings.ToLower(te.NodeName), + apt.RepoIDCharReplaceRegEx.ReplaceAllString( + strings.TrimPrefix(apitypes.StandardRepoFilePathMain, "/"), + "_", + ), + testAccResourceRepoIndex, + ), + ), + test.ResourceAttributesSet( + fmt.Sprintf("data.%s", testAccResourceRepoSelector), + []string{ + fmt.Sprintf("%s.#", apt.SchemaAttrNameComponents), + apt.SchemaAttrNameEnabled, + apt.SchemaAttrNameFilePath, + apt.SchemaAttrNameIndex, + apt.SchemaAttrNameNode, + fmt.Sprintf("%s.#", apt.SchemaAttrNamePackageTypes), + fmt.Sprintf("%s.#", apt.SchemaAttrNameSuites), + fmt.Sprintf("%s.#", apt.SchemaAttrNameURIs), + }, + ), + ), + }, + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + resource.ParallelTest( + t, resource.TestCase{ + ProtoV6ProviderFactories: te.AccProviders, + Steps: tt.steps, + }, + ) + }, + ) + } +} + +func TestAccDataSourceStandardRepo(t *testing.T) { + t.Helper() + t.Parallel() + + te := test.InitEnvironment(t) + + tests := []struct { + name string + steps []resource.TestStep + }{ + { + "read APT standard repository attributes", + []resource.TestStep{ + { + Config: fmt.Sprintf( + ` + data %q %q { + %s = %q # handle + %s = %q # node + } + `, + strings.Split(testAccResourceStandardRepoSelector, ".")[0], + strings.Split(testAccResourceStandardRepoSelector, ".")[1], + apt.SchemaAttrNameStandardHandle, apitypes.StandardRepoHandleKindNoSubscription, + apt.SchemaAttrNameNode, te.NodeName, + ), + // The provided attributes and computed attributes should be set. + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + fmt.Sprintf("data.%s", testAccResourceStandardRepoSelector), + apt.SchemaAttrNameTerraformID, + fmt.Sprintf( + "%s_%s_%s", + apt.ResourceStandardRepoIDPrefix, + strings.ToLower(te.NodeName), + apt.RepoIDCharReplaceRegEx.ReplaceAllString( + strings.TrimPrefix(apitypes.StandardRepoHandleKindNoSubscription.String(), "/"), + "_", + ), + ), + ), + test.ResourceAttributesSet( + fmt.Sprintf("data.%s", testAccResourceStandardRepoSelector), + // Note that we can not check for the following attributes because they are only available when the + // standard repository has been added to a source list: + // + // - apt.SchemaAttrNameFilePath (file_path) - will be set when parsing all configured repositories in all + // source list files. + // - apt.SchemaAttrNameIndex (index) - will be set when finding the repository within a source list file, + // based on the detected file path. + // - apt.SchemaAttrNameStandardStatus (status) - is only available when the standard has been configured. + []string{ + apt.SchemaAttrNameStandardDescription, + apt.SchemaAttrNameStandardHandle, + apt.SchemaAttrNameStandardName, + apt.SchemaAttrNameNode, + }, + ), + ), + }, + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + resource.ParallelTest( + t, resource.TestCase{ + ProtoV6ProviderFactories: te.AccProviders, + Steps: tt.steps, + }, + ) + }, + ) + } +} + +// Run tests for APT repository resource definitions with valid input where all required attributes are specified. +// Only the [Create], [Read] and [Update] method implementations of the +// [github.com/hashicorp/terraform-plugin-framework/resource.Resource] interface are tested in sequential steps because +// [Delete] is no-op due to the non-existing capability of the Proxmox VE API of deleting configured APT repository. +// +// [Create]: https://developer.hashicorp.com/terraform/plugin/framework/resources/create +// [Delete]: https://developer.hashicorp.com/terraform/plugin/framework/resources/delete +// [Read]: https://developer.hashicorp.com/terraform/plugin/framework/resources/read +// [Update]: https://developer.hashicorp.com/terraform/plugin/framework/resources/update +func TestAccResourceRepoValidInput(t *testing.T) { + t.Helper() + t.Parallel() + + te := test.InitEnvironment(t) + + resource.Test( + t, resource.TestCase{ + ProtoV6ProviderFactories: te.AccProviders, + Steps: []resource.TestStep{ + // Test the "Create" and "Read" implementations. + { + Config: fmt.Sprintf( + ` + resource %q %q { + %s = %t # enabled + %s = %q # file_path + %s = %d # index + %s = %q # node + } + `, + strings.Split(testAccResourceRepoSelector, ".")[0], + strings.Split(testAccResourceRepoSelector, ".")[1], + apt.SchemaAttrNameEnabled, apt.ResourceRepoActivationStatus, + // To ensure stable acceptance tests we must use one of the Proxmox VE default source lists that always + // exists on any (new) Proxmox VE node. + apt.SchemaAttrNameFilePath, apitypes.StandardRepoFilePathMain, + apt.SchemaAttrNameIndex, testAccResourceRepoIndex, + apt.SchemaAttrNameNode, te.NodeName, + ), + // The computed attributes should be set. + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue( + testAccResourceRepoSelector, + tfjsonpath.New(apt.SchemaAttrNameComponents), + knownvalue.ListPartial( + map[int]knownvalue.Check{ + // Use the same check for both entries because the sort order cannot be guaranteed. + 0: knownvalue.StringRegexp(regexp.MustCompile(`(contrib|main)`)), + 1: knownvalue.StringRegexp(regexp.MustCompile(`(contrib|main)`)), + }, + ), + ), + statecheck.ExpectKnownValue( + testAccResourceRepoSelector, + tfjsonpath.New(apt.SchemaAttrNamePackageTypes), + knownvalue.ListPartial( + map[int]knownvalue.Check{ + 0: knownvalue.StringRegexp(regexp.MustCompile(`(deb)`)), + }, + ), + ), + statecheck.ExpectKnownValue( + testAccResourceRepoSelector, + tfjsonpath.New(apt.SchemaAttrNameSuites), + knownvalue.ListPartial( + map[int]knownvalue.Check{ + // The possible Debian version is based on the official table of the Proxmox VE FAQ page: + // - https://pve.proxmox.com/wiki/FAQ#faq-support-table + // - https://www.thomas-krenn.com/en/wiki/Proxmox_VE#Proxmox_VE_8.x + // + // The required Proxmox VE version for this provider is of course also taken into account: + // - https://github.com/bpg/terraform-provider-proxmox?tab=readme-ov-file#requirements + 0: knownvalue.StringRegexp(regexp.MustCompile(`(bookworm)`)), + }, + ), + ), + statecheck.ExpectKnownValue( + testAccResourceRepoSelector, + tfjsonpath.New(apt.SchemaAttrNameURIs), + knownvalue.ListPartial( + map[int]knownvalue.Check{ + 0: knownvalue.StringRegexp(regexp.MustCompile(`https?://ftp\.([a-z]+\.)?debian\.org/debian`)), + }, + ), + ), + }, + // The provided attributes and computed attributes should be set. + Check: resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr( + testAccResourceRepoSelector, + apt.SchemaAttrNameComment, + // Expect any value or an empty string. + regexp.MustCompile(`(.*|^$)`), + ), + resource.TestCheckResourceAttr( + testAccResourceRepoSelector, + apt.SchemaAttrNameEnabled, + strconv.FormatBool(apt.ResourceRepoActivationStatus), + ), + resource.TestCheckResourceAttr( + testAccResourceRepoSelector, + apt.SchemaAttrNameFilePath, + apitypes.StandardRepoFilePathMain, + ), + resource.TestCheckResourceAttrSet(testAccResourceRepoSelector, apt.SchemaAttrNameFileType), + resource.TestCheckResourceAttr( + testAccResourceRepoSelector, + apt.SchemaAttrNameIndex, + strconv.FormatInt(testAccResourceRepoIndex, 10), + ), + resource.TestCheckResourceAttr(testAccResourceRepoSelector, apt.SchemaAttrNameNode, te.NodeName), + resource.TestCheckResourceAttr( + testAccResourceRepoSelector, + apt.SchemaAttrNameTerraformID, + fmt.Sprintf( + "%s_%s_%s_%d", + apt.ResourceRepoIDPrefix, + strings.ToLower(te.NodeName), + apt.RepoIDCharReplaceRegEx.ReplaceAllString( + strings.TrimPrefix(apitypes.StandardRepoFilePathMain, "/"), + "_", + ), + testAccResourceRepoIndex, + ), + ), + ), + }, + + // Test the "ImportState" implementation. + { + ImportState: true, + ImportStateId: fmt.Sprintf( + "%s,%s,%d", + strings.ToLower(te.NodeName), + apitypes.StandardRepoFilePathMain, + testAccResourceRepoIndex, + ), + ImportStateVerify: true, + ResourceName: testAccResourceRepoSelector, + }, + + // Test the "Update" implementation by toggling the activation status. + { + Config: fmt.Sprintf( + ` + resource %q %q { + %s = %t # enabled + %s = %q # file_path + %s = %d # index + %s = %q # node + } + `, + strings.Split(testAccResourceRepoSelector, ".")[0], + strings.Split(testAccResourceRepoSelector, ".")[1], + // Disable the repository which is enabled by default for created or imported resources. + apt.SchemaAttrNameEnabled, !apt.ResourceRepoActivationStatus, + // To ensure stable acceptance tests we must use one of the Proxmox VE default source lists that always + // exists on any (new) Proxmox VE node.s + apt.SchemaAttrNameFilePath, apitypes.StandardRepoFilePathMain, + apt.SchemaAttrNameIndex, testAccResourceRepoIndex, + apt.SchemaAttrNameNode, te.NodeName, + ), + // The provides attributes and some computed attributes should be set. + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + testAccResourceRepoSelector, + apt.SchemaAttrNameEnabled, + strconv.FormatBool(!apt.ResourceRepoActivationStatus), + ), + ), + }, + }, + }, + ) +} + +// Run tests for APT standard repository resource definitions with valid input where all required attributes are +// specified. +// Only the [Create] and [Read] method implementations of the +// [github.com/hashicorp/terraform-plugin-framework/resource.Resource] interface are tested in sequential steps because +// [Delete] and [Update] are no-op due to the non-existing capability of the Proxmox VE API of deleting or updating a +// configured APT standard repository. +// +// [Create]: https://developer.hashicorp.com/terraform/plugin/framework/resources/create +// [Delete]: https://developer.hashicorp.com/terraform/plugin/framework/resources/delete +// [Read]: https://developer.hashicorp.com/terraform/plugin/framework/resources/read +// [Update]: https://developer.hashicorp.com/terraform/plugin/framework/resources/update +func TestAccResourceStandardRepoValidInput(t *testing.T) { + t.Helper() + t.Parallel() + + te := test.InitEnvironment(t) + + resource.Test( + t, resource.TestCase{ + ProtoV6ProviderFactories: te.AccProviders, + Steps: []resource.TestStep{ + // Test the "Create" and "Read" implementations. + { + Config: fmt.Sprintf( + ` + resource %q %q { + %s = %q # handle + %s = %q # node + } + `, + strings.Split(testAccResourceStandardRepoSelector, ".")[0], + strings.Split(testAccResourceStandardRepoSelector, ".")[1], + apt.SchemaAttrNameStandardHandle, testAccResourceStandardRepoHandle, + apt.SchemaAttrNameNode, te.NodeName, + ), + // The provided attributes and computed attributes should be set. + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet( + testAccResourceStandardRepoSelector, + apt.SchemaAttrNameStandardDescription, + ), + resource.TestCheckResourceAttr( + testAccResourceStandardRepoSelector, + apt.SchemaAttrNameFilePath, + apitypes.StandardRepoFilePathMain, + ), + resource.TestCheckResourceAttr( + testAccResourceStandardRepoSelector, + apt.SchemaAttrNameStandardHandle, + testAccResourceStandardRepoHandle, + ), + resource.TestCheckResourceAttrSet(testAccResourceStandardRepoSelector, apt.SchemaAttrNameIndex), + resource.TestCheckResourceAttrSet(testAccResourceStandardRepoSelector, apt.SchemaAttrNameStandardName), + resource.TestCheckResourceAttr(testAccResourceStandardRepoSelector, apt.SchemaAttrNameNode, te.NodeName), + resource.TestCheckResourceAttr( + testAccResourceStandardRepoSelector, + apt.SchemaAttrNameStandardStatus, + // By default, newly added APT standard repositories are enabled. + strconv.Itoa(1), + ), + resource.TestCheckResourceAttr( + testAccResourceStandardRepoSelector, + apt.SchemaAttrNameTerraformID, + fmt.Sprintf( + "%s_%s_%s", + apt.ResourceStandardRepoIDPrefix, + strings.ToLower(te.NodeName), + apt.RepoIDCharReplaceRegEx.ReplaceAllString(testAccResourceStandardRepoHandle, "_"), + ), + ), + ), + }, + + // Test the "ImportState" implementation. + { + ImportState: true, + ImportStateId: fmt.Sprintf("%s,%s", strings.ToLower(te.NodeName), testAccResourceStandardRepoHandle), + ImportStateVerify: true, + ResourceName: testAccResourceStandardRepoSelector, + }, + }, + }, + ) +} diff --git a/fwprovider/nodes/apt/resource_repo.go b/fwprovider/nodes/apt/resource_repo.go new file mode 100644 index 00000000..eabb8ab9 --- /dev/null +++ b/fwprovider/nodes/apt/resource_repo.go @@ -0,0 +1,353 @@ +/* + * 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 apt + +import ( + "context" + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "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/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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/bpg/terraform-provider-proxmox/fwprovider/attribute" + "github.com/bpg/terraform-provider-proxmox/fwprovider/validators" + "github.com/bpg/terraform-provider-proxmox/proxmox" + api "github.com/bpg/terraform-provider-proxmox/proxmox/nodes/apt/repositories" + proxmoxtypes "github.com/bpg/terraform-provider-proxmox/proxmox/types" +) + +const ( + // ResourceRepoIDPrefix is the prefix for the resource ID of resourceRepo. + ResourceRepoIDPrefix = "apt_repository" + + // ResourceRepoActivationStatus is the default activation status for newly created or imported APT repositories. + // This reflects the same default value used by the Proxmox VE API when the "enabled" parameter is not set. + ResourceRepoActivationStatus = true +) + +// Ensure the resource implements the required interfaces. +var ( + _ resource.Resource = &resourceRepo{} + _ resource.ResourceWithConfigure = &resourceRepo{} + _ resource.ResourceWithImportState = &resourceRepo{} +) + +// resourceRepo contains the APT repository resource's internal data. +type resourceRepo struct { + // client is the Proxmox VE API client. + client proxmox.Client +} + +// read reads information about an APT repository from the Proxmox VE API. +// Note that the name of the node must be set before this method is called! +func (r *resourceRepo) read(ctx context.Context, rp *modelRepo) (bool, diag.Diagnostics) { + var diags diag.Diagnostics + + data, err := r.client.Node(rp.Node.ValueString()).APT().Repositories().Get(ctx) + if err != nil { + diags.AddError(fmt.Sprintf("Could not read APT repositories on node %v", rp.Node), err.Error()) + + return false, diags + } + + diags.Append(rp.importFromAPI(ctx, data)...) + + if diags.HasError() { + return false, diags + } + + return true, nil +} + +// readBack reads information about an APT repository from the Proxmox VE API and then updates the response state +// accordingly. +// Note that the Terraform resource identifier must be set in the state before this method is called! +func (r *resourceRepo) readBack(ctx context.Context, rp *modelRepo, diags *diag.Diagnostics, state *tfsdk.State) { + found, readDiags := r.read(ctx, rp) + + diags.Append(readDiags...) + + if !found { + diags.AddError( + "APT repository resource not found after update", + "Failed to find the resource when trying to read back the updated APT repository's data.", + ) + } + + if !diags.HasError() { + diags.Append(state.Set(ctx, *rp)...) + } +} + +// Configure adds the provider-configured client to the resource. +func (r *resourceRepo) 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 configuration type", + fmt.Sprintf("Expected *proxmox.Client, got: %T", req.ProviderData), + ) + + return + } + + r.client = client +} + +// Create modifies the activation state of an existing APT repository, including the addition of standard repositories +// to the repository lists. +// The name of this method might be a bit confusing for this resource, but this is due to the way how the Proxmox VE API +// works for APT repositories. +func (r *resourceRepo) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var rp modelRepo + + resp.Diagnostics.Append(req.Plan.Get(ctx, &rp)...) + + if resp.Diagnostics.HasError() { + return + } + + body := &api.ModifyRequestBody{ + Enabled: proxmoxtypes.CustomBool(rp.Enabled.ValueBool()), + Index: rp.Index.ValueInt64(), + Path: rp.FilePath.ValueString(), + } + + if err := r.client.Node(rp.Node.ValueString()).APT().Repositories().Modify(ctx, body); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Could not modify APT repository in file %v at index %v on node %v", rp.FilePath, rp.Index, rp.Node), + err.Error(), + ) + + return + } + + r.readBack(ctx, &rp, &resp.Diagnostics, &resp.State) +} + +// Delete is currently a no-op for APT repositories due to the non-existing capability of the Proxmox VE API of deleting +// a configured APT repository. +// Also see Terraform's "Delete" framework documentation about [recommendations] and [caveats]. +// +// [caveats]: https://developer.hashicorp.com/terraform/plugin/framework/resources/delete#caveats +// [recommendations]: https://developer.hashicorp.com/terraform/plugin/framework/resources/delete#recommendations +func (r *resourceRepo) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) { +} + +// ImportState imports an APT repository from the Proxmox VE API. +func (r *resourceRepo) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + rp := modelRepo{ + Enabled: types.BoolValue(ResourceRepoActivationStatus), + ID: types.StringValue(req.ID), + } + + idFormatErrMsg := "expected import ID as comma-separated list in format " + + "PROXMOX_VE_NODE_NAME,SOURCE_LIST_FILE_PATH,INDEX (e.g. pve,/etc/apt/sources.list,0)" + + parts := strings.Split(rp.ID.ValueString(), ",") + if len(parts) != 3 { + resp.Diagnostics.AddError("Invalid resource ID", fmt.Sprintf("%s, but got %v", idFormatErrMsg, rp.ID)) + + return + } + + rp.Node = types.StringValue(parts[0]) + + if !filepath.IsAbs(parts[1]) { + resp.Diagnostics.AddError( + "Invalid resource ID", + fmt.Sprintf("given source list file path %q is not an absolute path: %s", parts[1], idFormatErrMsg), + ) + + return + } + + rp.FilePath = types.StringValue(parts[1]) + + index, err := strconv.Atoi(parts[2]) + if err != nil { + resp.Diagnostics.AddError( + "Parse resource ID", + fmt.Sprintf("Failed to parse given import ID index parameter %q as number: %s", parts[2], idFormatErrMsg), + ) + + return + } + + rp.Index = types.Int64Value(int64(index)) + + resource.ImportStatePassthroughID(ctx, path.Root(SchemaAttrNameTerraformID), req, resp) + r.readBack(ctx, &rp, &resp.Diagnostics, &resp.State) +} + +// Metadata defines the name of the APT repository resource. +func (r *resourceRepo) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_" + ResourceRepoIDPrefix +} + +// Read reads the APT repository. +func (r *resourceRepo) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var rp modelRepo + + resp.Diagnostics.Append(req.State.Get(ctx, &rp)...) + + if resp.Diagnostics.HasError() { + return + } + + found, diags := r.read(ctx, &rp) + resp.Diagnostics.Append(diags...) + + if !resp.Diagnostics.HasError() { + if found { + resp.Diagnostics.Append(resp.State.Set(ctx, rp)...) + } else { + resp.State.RemoveResource(ctx) + } + } +} + +// Schema defines the schema for the APT repository. +func (r *resourceRepo) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages an APT repository of a Proxmox VE node.", + Attributes: map[string]schema.Attribute{ + SchemaAttrNameComment: schema.StringAttribute{ + Computed: true, + Description: "The associated comment.", + }, + SchemaAttrNameComponents: schema.ListAttribute{ + Computed: true, + Description: "The list of components.", + ElementType: types.StringType, + }, + SchemaAttrNameEnabled: schema.BoolAttribute{ + Computed: true, + Default: booldefault.StaticBool(ResourceRepoActivationStatus), + Description: "Indicates the activation status.", + Optional: true, + }, + SchemaAttrNameFilePath: schema.StringAttribute{ + Description: "The absolute path of the source list file that contains this repository.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Required: true, + Validators: []validator.String{ + validators.AbsoluteFilePathValidator(), + validators.NonEmptyString(), + }, + }, + SchemaAttrNameFileType: schema.StringAttribute{ + Computed: true, + Description: "The format of the defining source list file.", + }, + SchemaAttrNameIndex: schema.Int64Attribute{ + Description: "The index within the defining source list file.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + Required: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + SchemaAttrNameNode: schema.StringAttribute{ + Description: "The name of the target Proxmox VE node.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Required: true, + Validators: []validator.String{ + validators.NonEmptyString(), + }, + }, + SchemaAttrNamePackageTypes: schema.ListAttribute{ + Computed: true, + Description: "The list of package types.", + ElementType: types.StringType, + }, + SchemaAttrNameSuites: schema.ListAttribute{ + Computed: true, + Description: "The list of package distributions.", + ElementType: types.StringType, + }, + SchemaAttrNameTerraformID: attribute.ID("The unique identifier of this APT repository resource."), + SchemaAttrNameURIs: schema.ListAttribute{ + Computed: true, + Description: "The list of repository URIs.", + ElementType: types.StringType, + }, + }, + } +} + +// Update updates an existing APT repository. +func (r *resourceRepo) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var rpPlan modelRepo + + resp.Diagnostics.Append(req.Plan.Get(ctx, &rpPlan)...) + + if resp.Diagnostics.HasError() { + return + } + + body := &api.ModifyRequestBody{ + Enabled: proxmoxtypes.CustomBool(rpPlan.Enabled.ValueBool()), + Index: rpPlan.Index.ValueInt64(), + Path: rpPlan.FilePath.ValueString(), + } + + err := r.client.Node(rpPlan.Node.ValueString()).APT().Repositories().Modify(ctx, body) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf( + "Could not modify APT repository in file %v at index %v on node %v", + rpPlan.FilePath, + rpPlan.Index, + rpPlan.Node, + ), + err.Error(), + ) + + return + } + + r.readBack(ctx, &rpPlan, &resp.Diagnostics, &resp.State) +} + +// NewResourceRepo returns a new resource for managing an APT repository. +// This is a helper function to simplify the provider implementation. +func NewResourceRepo() resource.Resource { + return &resourceRepo{} +} diff --git a/fwprovider/nodes/apt/resource_standard_repo.go b/fwprovider/nodes/apt/resource_standard_repo.go new file mode 100644 index 00000000..99f8c204 --- /dev/null +++ b/fwprovider/nodes/apt/resource_standard_repo.go @@ -0,0 +1,286 @@ +/* + * 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 apt + +import ( + "context" + "fmt" + "strings" + + "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/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/bpg/terraform-provider-proxmox/fwprovider/attribute" + customtypes "github.com/bpg/terraform-provider-proxmox/fwprovider/types/nodes/apt" + "github.com/bpg/terraform-provider-proxmox/fwprovider/validators" + "github.com/bpg/terraform-provider-proxmox/proxmox" + api "github.com/bpg/terraform-provider-proxmox/proxmox/nodes/apt/repositories" +) + +const ( + // ResourceStandardRepoIDPrefix is the prefix for the resource ID of resourceStandardRepo. + ResourceStandardRepoIDPrefix = "apt_standard_repository" +) + +// Ensure the resource implements the required interfaces. +var ( + _ resource.Resource = &resourceStandardRepo{} + _ resource.ResourceWithConfigure = &resourceStandardRepo{} + _ resource.ResourceWithImportState = &resourceStandardRepo{} +) + +// resourceStandardRepo contains the APT standard repository resource's internal data. +type resourceStandardRepo struct { + // client is the Proxmox VE API client. + client proxmox.Client +} + +// read reads information about an APT standard repository from the Proxmox VE API. +// Note that the name of the node must be set before this method is called! +func (r *resourceStandardRepo) read(ctx context.Context, srp *modelStandardRepo) (bool, diag.Diagnostics) { + var diags diag.Diagnostics + + data, err := r.client.Node(srp.Node.ValueString()).APT().Repositories().Get(ctx) + if err != nil { + diags.AddError("Could not read APT repositories", err.Error()) + + return false, diags + } + + for _, stdRepo := range data.StandardRepos { + // Check if the APT standard repository is configured… + if stdRepo.Handle == srp.Handle.ValueString() && stdRepo.Status == nil { + // …handle the situation gracefully if not to signal that the repository has been removed outside of Terraform and + // must be added back again. + return false, diags + } + } + + srp.importFromAPI(ctx, data) + + return true, nil +} + +// readBack reads information about an APT standard repository from the Proxmox VE API and then updates the response +// state accordingly. +func (r *resourceStandardRepo) readBack( + ctx context.Context, + srp *modelStandardRepo, + diags *diag.Diagnostics, + state *tfsdk.State, +) { + found, readDiags := r.read(ctx, srp) + + diags.Append(readDiags...) + + if !found { + diags.AddError( + "APT standard repository resource not found after update", + "Failed to find the resource when trying to read back the updated APT standard repository's data.", + ) + } + + if !diags.HasError() { + diags.Append(state.Set(ctx, *srp)...) + } +} + +// Configure adds the provider-configured client to the resource. +func (r *resourceStandardRepo) 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 configuration type", + fmt.Sprintf("Expected *proxmox.Client, got: %T", req.ProviderData), + ) + + return + } + + r.client = client +} + +// Create adds an APT standard repository to the repository source lists. +// The name of this method might be a bit confusing for this resource, but this is due to the way how the Proxmox VE API +// works for APT standard repositories. +func (r *resourceStandardRepo) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var srp modelStandardRepo + + resp.Diagnostics.Append(req.Plan.Get(ctx, &srp)...) + + if resp.Diagnostics.HasError() { + return + } + + body := &api.AddRequestBody{ + Handle: srp.Handle.ValueString(), + Node: srp.Node.ValueString(), + } + + if err := r.client.Node(srp.Node.ValueString()).APT().Repositories().Add(ctx, body); err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Could not add APT standard repository with handle %v on node %v", srp.Handle, srp.Node), + err.Error(), + ) + } + + r.readBack(ctx, &srp, &resp.Diagnostics, &resp.State) +} + +// Delete is currently a no-op for APT standard repositories due to the non-existing capability of the Proxmox VE API +// of deleting a configured APT standard repository. +// Also see Terraform's "Delete" framework documentation about [recommendations] and [caveats]. +// +// [caveats]: https://developer.hashicorp.com/terraform/plugin/framework/resources/delete#caveats +// [recommendations]: https://developer.hashicorp.com/terraform/plugin/framework/resources/delete#recommendations +func (r *resourceStandardRepo) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) { +} + +// ImportState imports an APT standard repository from the Proxmox VE API. +func (r *resourceStandardRepo) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + srp := modelStandardRepo{ + ID: types.StringValue(req.ID), + } + + idFormatErrMsg := "expected import ID as comma-separated list in format " + + "PROXMOX_VE_NODE_NAME,STANDARD_REPOSITORY_HANDLE (e.g. pve,no-subscription)" + + parts := strings.Split(srp.ID.ValueString(), ",") + if len(parts) != 2 { + resp.Diagnostics.AddError("Invalid resource ID", fmt.Sprintf("%s, but got %v", idFormatErrMsg, srp.ID)) + + return + } + + srp.Node = types.StringValue(parts[0]) + srp.Handle = customtypes.StandardRepoHandleValue{StringValue: types.StringValue(parts[1])} + + resource.ImportStatePassthroughID(ctx, path.Root(SchemaAttrNameTerraformID), req, resp) + r.readBack(ctx, &srp, &resp.Diagnostics, &resp.State) +} + +// Metadata defines the name of the APT standard repository resource. +func (r *resourceStandardRepo) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_" + ResourceStandardRepoIDPrefix +} + +// Read reads the APT standard repository. +func (r *resourceStandardRepo) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var srp modelStandardRepo + + resp.Diagnostics.Append(req.State.Get(ctx, &srp)...) + + if resp.Diagnostics.HasError() { + return + } + + found, diags := r.read(ctx, &srp) + resp.Diagnostics.Append(diags...) + + if !resp.Diagnostics.HasError() { + if found { + resp.Diagnostics.Append(resp.State.Set(ctx, srp)...) + } else { + resp.State.RemoveResource(ctx) + } + } +} + +// Schema defines the schema for the APT standard repository. +func (r *resourceStandardRepo) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages an APT standard repository of a Proxmox VE node.", + Attributes: map[string]schema.Attribute{ + SchemaAttrNameStandardDescription: schema.StringAttribute{ + Computed: true, + Description: "The description of the APT standard repository.", + }, + SchemaAttrNameFilePath: schema.StringAttribute{ + Computed: true, + Description: "The absolute path of the source list file that contains this standard repository.", + }, + SchemaAttrNameStandardHandle: schema.StringAttribute{ + CustomType: customtypes.StandardRepoHandleType{}, + Description: "The handle of the APT standard repository.", + MarkdownDescription: "The handle of the APT standard repository. Must be `ceph-quincy-enterprise` | " + + "`ceph-quincy-no-subscription` | `ceph-quincy-test` | `ceph-reef-enterprise` | `ceph-reef-no-subscription` " + + "| `ceph-reef-test` | `enterprise` | `no-subscription` | `test`.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Required: true, + Validators: []validator.String{ + validators.NonEmptyString(), + }, + }, + SchemaAttrNameIndex: schema.Int64Attribute{ + Computed: true, + Description: "The index within the defining source list file.", + }, + SchemaAttrNameStandardName: schema.StringAttribute{ + Computed: true, + Description: "The name of the APT standard repository.", + }, + SchemaAttrNameNode: schema.StringAttribute{ + Description: "The name of the target Proxmox VE node.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Required: true, + Validators: []validator.String{ + validators.NonEmptyString(), + }, + }, + SchemaAttrNameStandardStatus: schema.Int64Attribute{ + Computed: true, + Description: "Indicates the activation status.", + }, + SchemaAttrNameTerraformID: attribute.ID( + "The unique identifier of this APT standard repository resource.", + ), + }, + } +} + +// Update is currently a no-op for APT repositories due to the non-existing capability of the Proxmox VE API of updating +// a configured APT standard repository. +// Also see Terraform's "Delete" framework documentation about [recommendations] and [caveats]. +// +// [caveats]: https://developer.hashicorp.com/terraform/plugin/framework/resources/delete#caveats +// [recommendations]: https://developer.hashicorp.com/terraform/plugin/framework/resources/delete#recommendations +func (r *resourceStandardRepo) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { +} + +// NewResourceStandardRepo returns a new resource for managing an APT standard repository. +// This is a helper function to simplify the provider implementation. +func NewResourceStandardRepo() resource.Resource { + return &resourceStandardRepo{} +} diff --git a/fwprovider/provider.go b/fwprovider/provider.go index 792c7761..24b74f95 100644 --- a/fwprovider/provider.go +++ b/fwprovider/provider.go @@ -29,6 +29,7 @@ import ( "github.com/bpg/terraform-provider-proxmox/fwprovider/ha" "github.com/bpg/terraform-provider-proxmox/fwprovider/hardwaremapping" "github.com/bpg/terraform-provider-proxmox/fwprovider/network" + "github.com/bpg/terraform-provider-proxmox/fwprovider/nodes/apt" "github.com/bpg/terraform-provider-proxmox/fwprovider/vm" "github.com/bpg/terraform-provider-proxmox/proxmox" "github.com/bpg/terraform-provider-proxmox/proxmox/api" @@ -443,6 +444,8 @@ func (p *proxmoxProvider) Resources(_ context.Context) []func() resource.Resourc return []func() resource.Resource{ NewClusterOptionsResource, NewDownloadFileResource, + apt.NewResourceRepo, + apt.NewResourceStandardRepo, access.NewACLResource, access.NewUserTokenResource, ha.NewHAGroupResource, @@ -458,6 +461,8 @@ func (p *proxmoxProvider) Resources(_ context.Context) []func() resource.Resourc func (p *proxmoxProvider) DataSources(_ context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ NewVersionDataSource, + apt.NewDataSourceRepo, + apt.NewDataSourceStandardRepo, ha.NewHAGroupDataSource, ha.NewHAGroupsDataSource, ha.NewHAResourceDataSource, diff --git a/fwprovider/types/nodes/apt/errors.go b/fwprovider/types/nodes/apt/errors.go new file mode 100644 index 00000000..aced392a --- /dev/null +++ b/fwprovider/types/nodes/apt/errors.go @@ -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 apt + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/function" +) + +//nolint:gochecknoglobals +var ( + // ErrValueConversion indicates an error while converting a value for a Proxmox VE API APT entity. + ErrValueConversion = func(format string, attrs ...any) error { + return function.NewFuncError(fmt.Sprintf(format, attrs...)) + } +) diff --git a/fwprovider/types/nodes/apt/standard_repo_handle.go b/fwprovider/types/nodes/apt/standard_repo_handle.go new file mode 100644 index 00000000..de5ea433 --- /dev/null +++ b/fwprovider/types/nodes/apt/standard_repo_handle.go @@ -0,0 +1,187 @@ +/* + 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 apt + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + apitypes "github.com/bpg/terraform-provider-proxmox/proxmox/types/nodes/apt/repositories" +) + +// Ensure the implementations satisfy the required interfaces. +var ( + _ basetypes.StringTypable = StandardRepoHandleType{} + _ basetypes.StringValuable = StandardRepoHandleValue{} +) + +// StandardRepoHandleType is a type that represents an APT standard repository handle. +type StandardRepoHandleType struct { + basetypes.StringType +} + +// StandardRepoHandleValue is a type that represents the value of an APT standard repository handle. +type StandardRepoHandleValue struct { + basetypes.StringValue + cvn apitypes.CephVersionName + kind apitypes.StandardRepoHandleKind +} + +// Equal returns true if the two types are equal. +func (t StandardRepoHandleType) Equal(o attr.Type) bool { + other, ok := o.(StandardRepoHandleType) + + if !ok { + return false + } + + return t.StringType.Equal(other.StringType) +} + +// String returns a string representation of the type. +func (t StandardRepoHandleType) String() string { + return "StandardRepoHandleType" +} + +// ValueFromString converts a string value to a basetypes.StringValuable. +func (t StandardRepoHandleType) ValueFromString(_ context.Context, in basetypes.StringValue) ( + basetypes.StringValuable, + diag.Diagnostics, +) { + value := StandardRepoHandleValue{ + StringValue: in, + cvn: apitypes.CephVersionNameUnknown, + kind: apitypes.StandardRepoHandleKindUnknown, + } + + // Parse the Ceph version name when the handle has the prefix. + if strings.HasPrefix(value.ValueString(), apitypes.CephStandardRepoHandlePrefix) { + parts := strings.Split(value.ValueString(), "-") + // Only continue when there is at least the Ceph prefix and the major version name in the handle. + if len(parts) > 2 { + cvn, err := apitypes.ParseCephVersionName(parts[1]) + if err == nil { + value.cvn = cvn + } + } + } + + // Parse the handle kind… + handleString := value.ValueString() + + if value.IsCephHandle() { + // …but ensure to strip Ceph specific parts from the handle string. + name, ok := strings.CutPrefix(handleString, fmt.Sprintf("%s-%s-", apitypes.CephStandardRepoHandlePrefix, value.cvn)) + if ok { + handleString = name + } + } + + switch handleString { + case apitypes.StandardRepoHandleKindEnterprise.String(): + value.kind = apitypes.StandardRepoHandleKindEnterprise + case apitypes.StandardRepoHandleKindNoSubscription.String(): + value.kind = apitypes.StandardRepoHandleKindNoSubscription + case apitypes.StandardRepoHandleKindTest.String(): + value.kind = apitypes.StandardRepoHandleKindTest + } + + return value, nil +} + +// ValueFromTerraform converts a Terraform value to a basetypes.StringValuable. +func (t StandardRepoHandleType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.StringType.ValueFromTerraform(ctx, in) + if err != nil { + return nil, errors.Join( + ErrValueConversion("unexpected error converting Terraform value to StringValue"), + err, + ) + } + + stringValue, ok := attrValue.(basetypes.StringValue) + if !ok { + return nil, ErrValueConversion("unexpected value type of %T", attrValue) + } + + stringValuable, diags := t.ValueFromString(ctx, stringValue) + if diags.HasError() { + return nil, ErrValueConversion( + "unexpected error converting StringValue to StringValuable: %v", + diags, + ) + } + + return stringValuable, nil +} + +// ValueType returns the underlying value type. +func (t StandardRepoHandleType) ValueType(_ context.Context) attr.Value { + return StandardRepoHandleValue{} +} + +// CephVersionName returns the corresponding Ceph major version name. +// Note that the version will be [apitypes.CephVersionNameUnknown] when not a Ceph specific handle! +func (v StandardRepoHandleValue) CephVersionName() apitypes.CephVersionName { + return v.cvn +} + +// ComponentName returns the corresponding component name. +func (v StandardRepoHandleValue) ComponentName() string { + if v.cvn == apitypes.CephVersionNameUnknown && v.kind != apitypes.StandardRepoHandleKindUnknown { + // For whatever reason the non-Ceph handle "test" kind does not use a dash in between the "pve" prefix. + if v.kind == apitypes.StandardRepoHandleKindTest { + return fmt.Sprintf("pve%s", v.kind) + } + + return fmt.Sprintf("pve-%s", v.kind) + } + + return v.kind.String() +} + +// Equal returns true if the two values are equal. +func (v StandardRepoHandleValue) Equal(o attr.Value) bool { + other, ok := o.(StandardRepoHandleValue) + + if !ok { + return false + } + + return v.StringValue.Equal(other.StringValue) +} + +// IsCephHandle indicates if this is a Ceph APT standard repository. +func (v StandardRepoHandleValue) IsCephHandle() bool { + return v.cvn != apitypes.CephVersionNameUnknown +} + +// IsSupportedFilePath returns whether the handle is supported for the given source list file path. +func (v StandardRepoHandleValue) IsSupportedFilePath(filePath string) bool { + switch filePath { + case apitypes.StandardRepoFilePathCeph: + return v.IsCephHandle() + case apitypes.StandardRepoFilePathEnterprise: + return !v.IsCephHandle() && v.kind == apitypes.StandardRepoHandleKindEnterprise + case apitypes.StandardRepoFilePathMain: + return !v.IsCephHandle() && v.kind != apitypes.StandardRepoHandleKindEnterprise + default: + return false + } +} + +// Type returns the type of the value. +func (v StandardRepoHandleValue) Type(_ context.Context) attr.Type { + return StandardRepoHandleType{} +} diff --git a/fwprovider/types/nodes/apt/standard_repo_handle_test.go b/fwprovider/types/nodes/apt/standard_repo_handle_test.go new file mode 100644 index 00000000..04e878f2 --- /dev/null +++ b/fwprovider/types/nodes/apt/standard_repo_handle_test.go @@ -0,0 +1,123 @@ +/* + * 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 apt + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tftypes" + + apitypes "github.com/bpg/terraform-provider-proxmox/proxmox/types/nodes/apt/repositories" +) + +func TestStandardRepoHandleValueFromTerraform(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + val tftypes.Value + expected func(val StandardRepoHandleValue) bool + expectError bool + }{ + "null value": { + val: tftypes.NewValue(tftypes.String, nil), + expected: func(val StandardRepoHandleValue) bool { + return val.IsNull() + }, + }, + "unknown value": { + val: tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + expected: func(val StandardRepoHandleValue) bool { + return val.IsUnknown() + }, + }, + "invalid Ceph APT standard repository handle": { + val: tftypes.NewValue(tftypes.String, "ceph-foo-enterprise"), + expected: func(val StandardRepoHandleValue) bool { + return val.kind == apitypes.StandardRepoHandleKindUnknown && + !val.IsCephHandle() && + !val.IsSupportedFilePath(apitypes.StandardRepoFilePathCeph) && + val.ComponentName() == "unknown" && + val.ValueString() == "ceph-foo-enterprise" + }, + }, + "valid Ceph APT standard repository handle": { + val: tftypes.NewValue(tftypes.String, "ceph-quincy-enterprise"), + expected: func(val StandardRepoHandleValue) bool { + return val.kind == apitypes.StandardRepoHandleKindEnterprise && + val.CephVersionName() == apitypes.CephVersionNameQuincy && + val.IsCephHandle() && + val.IsSupportedFilePath(apitypes.StandardRepoFilePathCeph) && + val.ComponentName() == "enterprise" && + val.ValueString() == "ceph-quincy-enterprise" + }, + }, + `valid Ceph "test" APT standard repository handle`: { + val: tftypes.NewValue(tftypes.String, "ceph-reef-test"), + expected: func(val StandardRepoHandleValue) bool { + return val.kind == apitypes.StandardRepoHandleKindTest && + val.CephVersionName() == apitypes.CephVersionNameReef && + val.IsCephHandle() && + val.IsSupportedFilePath(apitypes.StandardRepoFilePathCeph) && + val.ComponentName() == "test" && + val.ValueString() == "ceph-reef-test" + }, + }, + "invalid APT repository handle": { + val: tftypes.NewValue(tftypes.String, "foo-bar"), + expected: func(val StandardRepoHandleValue) bool { + return val.kind == apitypes.StandardRepoHandleKindUnknown && + !val.IsCephHandle() && + !val.IsSupportedFilePath(apitypes.StandardRepoFilePathCeph) && + val.ValueString() == "foo-bar" + }, + }, + `valid APT "no subscription" repository handle`: { + val: tftypes.NewValue(tftypes.String, "no-subscription"), + expected: func(val StandardRepoHandleValue) bool { + return val.kind == apitypes.StandardRepoHandleKindNoSubscription && + !val.IsCephHandle() && + val.IsSupportedFilePath(apitypes.StandardRepoFilePathMain) && + val.ComponentName() == "pve-no-subscription" && + val.ValueString() == "no-subscription" + }, + }, + `valid APT "test" repository handle`: { + val: tftypes.NewValue(tftypes.String, "test"), + expected: func(val StandardRepoHandleValue) bool { + return val.kind == apitypes.StandardRepoHandleKindTest && + !val.IsCephHandle() && + val.IsSupportedFilePath(apitypes.StandardRepoFilePathMain) && + val.ComponentName() == "pvetest" && + val.ValueString() == "test" + }, + }, + } + + for name, test := range tests { + t.Run( + name, func(t *testing.T) { + t.Parallel() + + ctx := context.TODO() + val, err := StandardRepoHandleType{}.ValueFromTerraform(ctx, test.val) + + if err == nil && test.expectError { + t.Fatal("expected error, got no error") + } + + if err != nil && !test.expectError { + t.Fatalf("got unexpected error: %s", err) + } + + if !test.expected(val.(StandardRepoHandleValue)) { + t.Errorf("unexpected result") + } + }, + ) + } +} diff --git a/fwprovider/validators/strings.go b/fwprovider/validators/strings.go new file mode 100644 index 00000000..0c6177da --- /dev/null +++ b/fwprovider/validators/strings.go @@ -0,0 +1,39 @@ +/* + 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 validators + +import ( + "fmt" + "path/filepath" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// AbsoluteFilePathValidator validates that a string is an absolute file path. +func AbsoluteFilePathValidator() validator.String { + return NewParseValidator( + func(s string) (string, error) { + if filepath.IsAbs(s) { + return s, nil + } + + return s, fmt.Errorf("%q is not an absolute path", s) + }, + "must be an absolute file path", + ) +} + +// NonEmptyString returns a new validator to ensure a non-empty string. +func NonEmptyString() validator.String { + return stringvalidator.All( + stringvalidator.UTF8LengthAtLeast(1), + stringvalidator.RegexMatches(regexp.MustCompile(`^\S|^$`), "must not start with whitespace"), + stringvalidator.RegexMatches(regexp.MustCompile(`\S$|^$`), "must not end with whitespace"), + ) +} diff --git a/proxmox/nodes/apt/client.go b/proxmox/nodes/apt/client.go new file mode 100644 index 00000000..2170c837 --- /dev/null +++ b/proxmox/nodes/apt/client.go @@ -0,0 +1,29 @@ +/* + * 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 apt + +import ( + "fmt" + + "github.com/bpg/terraform-provider-proxmox/proxmox/api" + "github.com/bpg/terraform-provider-proxmox/proxmox/nodes/apt/repositories" +) + +// Client is an interface for accessing the Proxmox cluster API. +type Client struct { + api.Client +} + +// ExpandPath expands a relative path to a full cluster API path. +func (c *Client) ExpandPath(path string) string { + return c.Client.ExpandPath(fmt.Sprintf("apt/%s", path)) +} + +// Repositories returns a client for managing APT repositories. +func (c *Client) Repositories() *repositories.Client { + return &repositories.Client{Client: c} +} diff --git a/proxmox/nodes/apt/repositories/repositories.go b/proxmox/nodes/apt/repositories/repositories.go new file mode 100644 index 00000000..d9992cfa --- /dev/null +++ b/proxmox/nodes/apt/repositories/repositories.go @@ -0,0 +1,72 @@ +/* + * 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 repositories + +import ( + "context" + "fmt" + "net/http" + + "github.com/bpg/terraform-provider-proxmox/proxmox/api" +) + +// Client is an interface for accessing the Proxmox node APT repositories API. +type Client struct { + api.Client +} + +// basePath returns the expanded APT repositories API base path. +func (c *Client) basePath() string { + return c.Client.ExpandPath("repositories") +} + +// Add adds an APT standard repository entry. +func (c *Client) Add(ctx context.Context, data *AddRequestBody) error { + err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath(), data, nil) + if err != nil { + return fmt.Errorf("adding APT standard repository: %w", err) + } + + return nil +} + +// ExpandPath expands a relative path to a full APT repositories API path. +func (c *Client) ExpandPath() string { + return c.basePath() +} + +// Get retrieves all APT repositories. +func (c *Client) Get(ctx context.Context) (*GetResponseData, error) { + resBody := &GetResponseBody{} + + err := c.DoRequest(ctx, http.MethodGet, c.ExpandPath(), nil, resBody) + if err != nil { + return nil, fmt.Errorf("reading APT repositories: %w", err) + } + + if resBody.Data == nil { + return nil, api.ErrNoDataObjectInResponse + } + + return resBody.Data, nil +} + +// Modify modifies the activation status of an APT repository. +func (c *Client) Modify(ctx context.Context, data *ModifyRequestBody) error { + err := c.DoRequest(ctx, http.MethodPost, c.ExpandPath(), data, nil) + if err != nil { + return fmt.Errorf( + `modifying APT repository in file %s at index %d to activation state %v: %w`, + data.Path, + data.Index, + data.Enabled, + err, + ) + } + + return nil +} diff --git a/proxmox/nodes/apt/repositories/types.go b/proxmox/nodes/apt/repositories/types.go new file mode 100644 index 00000000..6428ac1b --- /dev/null +++ b/proxmox/nodes/apt/repositories/types.go @@ -0,0 +1,110 @@ +/* + * 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 repositories + +import ( + "github.com/bpg/terraform-provider-proxmox/proxmox/types" +) + +// baseData contains common data for APT repository API calls. +type baseData struct { + Digest *string `json:"digest,omitempty" url:"digest,omitempty"` +} + +// file contains the data of a parsed APT repository file. +type file struct { + // FileType is the format of the file. + FileType string `json:"file-type"` + + // Path is the path to the repository file. + Path string `json:"path"` + + // Repositories is the list of parsed repositories. + Repositories []*repo `json:"repositories"` +} + +// repo contains the data of an APT repository from a parsed file. +type repo struct { + // Comment is the associated comment. + Comment *string `json:"Comment,omitempty"` + + // Components is the list of repository components. + Components []string `json:"Components,omitempty"` + + // Enabled indicates whether the repository is enabled. + Enabled types.CustomBool `json:"Enabled"` + + // FileTpe is the format of the defining file. + FileType string `json:"FileType"` + + // PackageTypes is the list of package types. + PackageTypes []string `json:"Types"` + + // Suites is the list of package distributions. + Suites []string `json:"Suites"` + + // URIs is the list of repository URIs. + URIs []string `json:"URIs"` +} + +// standardRepo contains the data for an APT standard repository. +type standardRepo struct { + // Description is the description of the APT standard repository. + Description *string `json:"description,omitempty"` + + // Handle is the pre-defined handle of the APT standard repository. + Handle string `json:"handle"` + + // Name is the human-readable name of the APT standard repository. + Name string `json:"Name"` + + // Status is the activation status of the APT standard repository. + // Can be either 0 (disabled) or 1 (enabled). + Status *int64 `json:"status,omitempty"` +} + +// AddRequestBody contains the body for an APT repository PUT request to add a standard repository. +type AddRequestBody struct { + baseData + + // Handle is the pre-defined handle of the APT standard repository. + Handle string `json:"handle" url:"handle"` + + // Node is the name of the target Proxmox VE node. + Node string `json:"node" url:"node"` +} + +// GetResponseBody is the body from an APT repository GET response. +type GetResponseBody struct { + Data *GetResponseData `json:"data,omitempty"` +} + +// GetResponseData contains the data from an APT repository GET response. +type GetResponseData struct { + baseData + + // Files contains the APT repository files. + Files []*file `json:"files,omitempty"` + + // StandardRepos contains the APT standard repositories. + StandardRepos []*standardRepo `json:"standard-repos,omitempty"` +} + +// ModifyRequestBody contains the body for an APT repository POST request to modify a repository. +type ModifyRequestBody struct { + baseData + + // Enabled indicates the activation status of the APT repository. + // Must either be 0 (disabled) or 1 (enabled). + Enabled types.CustomBool `json:"enabled" url:"enabled,int"` + + // Index is the index of the APT repository within the defining repository source file. + Index int64 `json:"handle" url:"index"` + + // Path is the absolute path of the defining source file for the APT repository. + Path string `json:"path" url:"path"` +} diff --git a/proxmox/nodes/client.go b/proxmox/nodes/client.go index 2e5fe5e7..24be4769 100644 --- a/proxmox/nodes/client.go +++ b/proxmox/nodes/client.go @@ -11,6 +11,7 @@ import ( "net/url" "github.com/bpg/terraform-provider-proxmox/proxmox/api" + "github.com/bpg/terraform-provider-proxmox/proxmox/nodes/apt" "github.com/bpg/terraform-provider-proxmox/proxmox/nodes/containers" "github.com/bpg/terraform-provider-proxmox/proxmox/nodes/storage" "github.com/bpg/terraform-provider-proxmox/proxmox/nodes/tasks" @@ -28,6 +29,13 @@ func (c *Client) ExpandPath(path string) string { return fmt.Sprintf("nodes/%s/%s", url.PathEscape(c.NodeName), path) } +// APT returns a client for managing APT related settings. +func (c *Client) APT() *apt.Client { + return &apt.Client{ + Client: c, + } +} + // Container returns a client for managing a specific container. func (c *Client) Container(vmID int) *containers.Client { return &containers.Client{ diff --git a/proxmox/nodes/network.go b/proxmox/nodes/network.go index 7e810928..7fe363d7 100644 --- a/proxmox/nodes/network.go +++ b/proxmox/nodes/network.go @@ -78,7 +78,7 @@ func (c *Client) ReloadNetworkConfiguration(ctx context.Context) error { func() error { err := c.DoRequest(ctx, http.MethodPut, c.ExpandPath("network"), nil, resBody) if err != nil { - return err //nolint:wrapcheck + return err } if resBody.Data == nil { diff --git a/proxmox/types/nodes/apt/repositories/ceph_version_name.go b/proxmox/types/nodes/apt/repositories/ceph_version_name.go new file mode 100644 index 00000000..68344413 --- /dev/null +++ b/proxmox/types/nodes/apt/repositories/ceph_version_name.go @@ -0,0 +1,105 @@ +/* + 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 repositories + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + + "github.com/google/go-querystring/query" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const ( + // CephStandardRepoHandlePrefix is the prefix for Ceph APT standard repositories. + CephStandardRepoHandlePrefix = "ceph" +) + +// Do not modify any of these package-global variables as they act as safer variants compared to "iota" based constants! +// +//nolint:gochecknoglobals +var ( + // CephVersionNameQuincy is the name for the "Quincy" Ceph major version. + CephVersionNameQuincy = CephVersionName{"quincy"} + + // CephVersionNameReef is the name for the "Reef" Ceph major version. + CephVersionNameReef = CephVersionName{"reef"} + + // CephVersionNameUnknown is the name for an unknown Ceph major version. + CephVersionNameUnknown = CephVersionName{"unknown"} +) + +// Ensure the hardware mapping type supports required interfaces. +var ( + _ fmt.Stringer = new(CephVersionName) + _ json.Marshaler = new(CephVersionName) + _ json.Unmarshaler = new(CephVersionName) + _ query.Encoder = new(CephVersionName) +) + +// CephVersionName is the name a Ceph major version. +type CephVersionName struct { + name string +} + +// EncodeValues encodes Ceph major version name field into a URL-encoded set of values. +func (n CephVersionName) EncodeValues(key string, v *url.Values) error { + v.Add(key, n.String()) + return nil +} + +// MarshalJSON marshals a Ceph major version name into JSON value. +func (n CephVersionName) MarshalJSON() ([]byte, error) { + bytes, err := json.Marshal(n.String()) + if err != nil { + return nil, errors.Join(ErrCephVersionNameMarshal, err) + } + + return bytes, nil +} + +// String converts a CephVersionName value into a string. +func (n CephVersionName) String() string { + return n.name +} + +// ToValue converts a Ceph major version name into a Terraform value. +func (n CephVersionName) ToValue() types.String { + return types.StringValue(n.String()) +} + +// UnmarshalJSON unmarshals a Ceph major version name. +func (n *CephVersionName) UnmarshalJSON(b []byte) error { + var rtString string + + err := json.Unmarshal(b, &rtString) + if err != nil { + return errors.Join(ErrCephVersionNameUnmarshal, err) + } + + resType, err := ParseCephVersionName(rtString) + if err == nil { + *n = resType + } + + return err +} + +// ParseCephVersionName converts the string representation of a Ceph major version name into the corresponding value. +// An error is returned if the input string does not match any known type. +func ParseCephVersionName(input string) (CephVersionName, error) { + switch input { + case CephVersionNameQuincy.String(): + return CephVersionNameQuincy, nil + case CephVersionNameReef.String(): + return CephVersionNameReef, nil + default: + return CephVersionName{}, ErrCephVersionNameIllegal(input) + } +} diff --git a/proxmox/types/nodes/apt/repositories/errors.go b/proxmox/types/nodes/apt/repositories/errors.go new file mode 100644 index 00000000..8f0f5104 --- /dev/null +++ b/proxmox/types/nodes/apt/repositories/errors.go @@ -0,0 +1,38 @@ +/* + 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 repositories + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/function" +) + +//nolint:gochecknoglobals +var ( + // ErrCephVersionNameIllegal indicates an error for an illegal Ceph major version name. + ErrCephVersionNameIllegal = func(name string) error { + return function.NewFuncError(fmt.Sprintf("illegal Ceph major version name %q", name)) + } + + // ErrCephVersionNameMarshal indicates an error while marshalling a Ceph major version name. + ErrCephVersionNameMarshal = function.NewFuncError("cannot marshal Ceph major version name") + + // ErrCephVersionNameUnmarshal indicates an error while unmarshalling a Ceph major version name. + ErrCephVersionNameUnmarshal = function.NewFuncError("cannot unmarshal Ceph major version name") + + // ErrStandardRepoHandleKindIllegal indicates an error for an illegal APT standard repository handle. + ErrStandardRepoHandleKindIllegal = func(handle string) error { + return function.NewFuncError(fmt.Sprintf("illegal APT standard repository handle kind %q", handle)) + } + + // ErrStandardRepoHandleKindMarshal indicates an error while marshalling an APT standard repository handle kind. + ErrStandardRepoHandleKindMarshal = function.NewFuncError("cannot marshal APT standard repository handle kind") + + // ErrStandardRepoHandleKindUnmarshal indicates an error while unmarshalling an APT standard repository handle kind. + ErrStandardRepoHandleKindUnmarshal = function.NewFuncError("cannot unmarshal APT standard repository handle kind") +) diff --git a/proxmox/types/nodes/apt/repositories/standard_repo_file_path.go b/proxmox/types/nodes/apt/repositories/standard_repo_file_path.go new file mode 100644 index 00000000..549929c3 --- /dev/null +++ b/proxmox/types/nodes/apt/repositories/standard_repo_file_path.go @@ -0,0 +1,22 @@ +/* + 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 repositories + +// Note that "hard-coded" slashes are used since Proxmox VE is built on top of Linux (Debian). +const ( + // StandardRepoFilePathCeph is the default Proxmox VE pre-defined (absolute) file path for the APT source list of Ceph + // repositories. + StandardRepoFilePathCeph = "/etc/apt/sources.list.d/ceph.list" + + // StandardRepoFilePathEnterprise is the default Proxmox VE pre-defined (absolute) file path for the APT source list + // of enterprise repositories. + StandardRepoFilePathEnterprise = "/etc/apt/sources.list.d/pve-enterprise.list" + + // StandardRepoFilePathMain is the default Proxmox VE pre-defined (absolute) file path for the APT source list of main + // OS (Debian) repositories. + StandardRepoFilePathMain = "/etc/apt/sources.list" +) diff --git a/proxmox/types/nodes/apt/repositories/standard_repo_handle_kind.go b/proxmox/types/nodes/apt/repositories/standard_repo_handle_kind.go new file mode 100644 index 00000000..e132492b --- /dev/null +++ b/proxmox/types/nodes/apt/repositories/standard_repo_handle_kind.go @@ -0,0 +1,109 @@ +/* + 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 repositories + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + + "github.com/google/go-querystring/query" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Do not modify any of these package-global variables as they act as safer variants compared to "iota" based constants! +// +//nolint:gochecknoglobals +var ( + // StandardRepoHandleKindEnterprise is the name for the "Enterprise" APT standard repository handle kind. + StandardRepoHandleKindEnterprise = StandardRepoHandleKind{"enterprise"} + + // StandardRepoHandleKindNoSubscription is the name for the "No Subscription" APT standard repository handle kind. + StandardRepoHandleKindNoSubscription = StandardRepoHandleKind{"no-subscription"} + + // StandardRepoHandleKindTest is the name for the "Test" APT standard repository handle kind. + StandardRepoHandleKindTest = StandardRepoHandleKind{"test"} + + // StandardRepoHandleKindUnknown is the name for an unknown APT standard repository handle kind. + StandardRepoHandleKindUnknown = StandardRepoHandleKind{"unknown"} +) + +// Ensure the hardware mapping type supports required interfaces. +var ( + _ fmt.Stringer = new(StandardRepoHandleKind) + _ json.Marshaler = new(StandardRepoHandleKind) + _ json.Unmarshaler = new(StandardRepoHandleKind) + _ query.Encoder = new(StandardRepoHandleKind) +) + +// StandardRepoHandleKind is the kind of APT standard repository handle. +type StandardRepoHandleKind struct { + handle string +} + +// EncodeValues encodes the APT standard repository handle kind field into a URL-encoded set of values. +func (h *StandardRepoHandleKind) EncodeValues(key string, v *url.Values) error { + v.Add(key, h.String()) + return nil +} + +// MarshalJSON marshals an APT standard repository handle kind into JSON value. +func (h *StandardRepoHandleKind) MarshalJSON() ([]byte, error) { + bytes, err := json.Marshal(h.String()) + if err != nil { + return nil, errors.Join(ErrStandardRepoHandleKindMarshal, err) + } + + return bytes, nil +} + +// String converts a StandardRepoHandleKind value into a string. +func (h StandardRepoHandleKind) String() string { + return h.handle +} + +// ToValue converts an APT standard repository handle kind into a Terraform value. +func (h *StandardRepoHandleKind) ToValue() types.String { + return types.StringValue(h.String()) +} + +// UnmarshalJSON unmarshals an APT standard repository handle kind. +func (h *StandardRepoHandleKind) UnmarshalJSON(b []byte) error { + var rtString string + + err := json.Unmarshal(b, &rtString) + if err != nil { + return errors.Join(ErrStandardRepoHandleKindUnmarshal, err) + } + + resType, err := ParseStandardRepoHandleKind(rtString) + if err == nil { + *h = resType + } + + return err +} + +// ParseStandardRepoHandleKind converts the string representation of an APT standard repository handle kind into the +// corresponding type. +// StandardRepoHandleKindUnknown and an error is returned if the input string does not match any known handle kind. +func ParseStandardRepoHandleKind(input string) (StandardRepoHandleKind, error) { + switch input { + case StandardRepoHandleKindEnterprise.String(): + return StandardRepoHandleKindEnterprise, nil + case StandardRepoHandleKindNoSubscription.String(): + return StandardRepoHandleKindNoSubscription, nil + case StandardRepoHandleKindTest.String(): + return StandardRepoHandleKindTest, nil + } + + return StandardRepoHandleKindUnknown, fmt.Errorf( + "parse APT standard repository handle kind: %w", + ErrStandardRepoHandleKindIllegal(input), + ) +} diff --git a/tools/tools.go b/tools/tools.go index ff36d17f..a1e2e6d7 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -28,6 +28,8 @@ import ( // Temporary: while migrating to the TF framework, we need to copy the generated docs to the right place // for the resources / data sources that have been migrated. //go:generate cp -R ../build/docs-gen/guides/ ../docs/guides/ +//go:generate cp ../build/docs-gen/data-sources/virtual_environment_apt_repository.md ../docs/data-sources/ +//go:generate cp ../build/docs-gen/data-sources/virtual_environment_apt_standard_repository.md ../docs/data-sources/ //go:generate cp ../build/docs-gen/data-sources/virtual_environment_hagroup.md ../docs/data-sources/ //go:generate cp ../build/docs-gen/data-sources/virtual_environment_hagroups.md ../docs/data-sources/ //go:generate cp ../build/docs-gen/data-sources/virtual_environment_hardware_mapping_pci.md ../docs/data-sources/ @@ -38,6 +40,8 @@ import ( //go:generate cp ../build/docs-gen/data-sources/virtual_environment_version.md ../docs/data-sources/ //go:generate cp ../build/docs-gen/data-sources/virtual_environment_vm2.md ../docs/data-sources/ //go:generate cp ../build/docs-gen/resources/virtual_environment_acl.md ../docs/resources/ +//go:generate cp ../build/docs-gen/resources/virtual_environment_apt_repository.md ../docs/resources/ +//go:generate cp ../build/docs-gen/resources/virtual_environment_apt_standard_repository.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_hagroup.md ../docs/resources/