Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

network_integration: add network_integration resource #173

Merged
merged 3 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/resources/network_integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# incus_network_integration

Manage integrations between the local Incus deployment and remote networks hosted on Incus or other platforms. Currently available only for [OVN networks](https://linuxcontainers.org/incus/docs/main/reference/network_ovn/#network-ovn).

## Basic Example

```hcl
resource "incus_network_integration" "this" {
name = "ovn-region"
type = "ovn"

config = {
"ovn.northbound_connection" = "tcp:[192.0.2.12]:6645,tcp:[192.0.3.13]:6645,tcp:[192.0.3.14]:6645"
"ovn.southbound_connection" = "tcp:[192.0.2.12]:6646,tcp:[192.0.3.13]:6646,tcp:[192.0.3.14]:6646"
}
}
```

## Peer Example

```hcl
resource "incus_network" "default" {
name = "default"
type = "ovn"

config = {
"ipv4.address" = "192.168.2.0/24"
"ipv4.nat" = "true"
}
}

resource "incus_network_integration" "this" {
name = "ovn-region"
type = "ovn"

config = {
"ovn.northbound_connection" = "tcp:[192.0.2.12]:6645,tcp:[192.0.3.13]:6645,tcp:[192.0.3.14]:6645"
"ovn.southbound_connection" = "tcp:[192.0.2.12]:6646,tcp:[192.0.3.13]:6646,tcp:[192.0.3.14]:6646"
}
}

resource "incus_network_peer" "this" {
name = "ovn-peer"
network = incus_network.default.name
target_integration = incus_network_integration.this.name
}
```

## Argument Reference

* `name` - **Required** - Name of the network integration.

* `type` **Required** - The type of the network integration. Currently, only supports `ovn` type.

* `description` *Optional* - Description of the network integration.

* `project` - *Optional* - Name of the project where the network will be created.

* `remote` - *Optional* - The remote in which the resource will be created. If
not provided, the provider's default remote will be used.

* `config` - *Optional* - Map of key/value pairs of [network integration config settings](https://linuxcontainers.org/incus/docs/main/howto/network_integrations/)
282 changes: 282 additions & 0 deletions internal/network/resource_network_integration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
package network

import (
"context"
"fmt"

"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/mapdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"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"
incus "github.com/lxc/incus/v6/client"
"github.com/lxc/incus/v6/shared/api"

"github.com/lxc/terraform-provider-incus/internal/common"
"github.com/lxc/terraform-provider-incus/internal/errors"
provider_config "github.com/lxc/terraform-provider-incus/internal/provider-config"
)

// NetworkIntegrationModel resource data model that matches the schema.
type NetworkIntegrationModel struct {
Name types.String `tfsdk:"name"`
Type types.String `tfsdk:"type"`
Description types.String `tfsdk:"description"`
Project types.String `tfsdk:"project"`
Remote types.String `tfsdk:"remote"`
Config types.Map `tfsdk:"config"`
}

// NetworkIntegrationResource represent network integration resource.
type NetworkIntegrationResource struct {
provider *provider_config.IncusProviderConfig
}

// NewNetworkIntegrationResource returns a new network integration resource.
func NewNetworkIntegrationResource() resource.Resource {
return &NetworkIntegrationResource{}
}

// Metadata for NetworkIntegrationResource.
func (r NetworkIntegrationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = fmt.Sprintf("%s_network_integration", req.ProviderTypeName)
}

// Schema for NetworkIntegrationResource.
func (r NetworkIntegrationResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, we need an additional check here to make sure, that the user does not provide the empty string "" as name, since this causes an error on Incus side while Terraform does accept it "" != null.

Suggested change
},
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
},

@maveonair, @stgraber this is maybe a general question, since I see this to be missing also for other resources on the name attribute.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, yeah, empty names are usually not allowed in the API.

The one exception there being for instances where an empty name is allowed but leads to Incus generating a random instance name for you, which may by itself cause issues with Terraform.

I think it'd make sense for all name properties to be required to be at least 1 character long, then leave the more detailed validation after that to Incus.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100%, we should make the name mandatory if we need it.... I have to say I've never tried using an empty string before. Great catch!

Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
},
},

"type": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.OneOf("ovn"),
},
},

"description": schema.StringAttribute{
Optional: true,
Computed: true,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is description Computed? It is provided in the Terraform config, persisted in Incus and returned by the API, so this is a regular attribute in my opinion. Do I miss something?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, you're correct, this isn't a field which Incus computes for you, so what's pushed by Terraform is what's stored, therefore it shouldn't be marked as computed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't use a default value if the field is not Computed:

$ TF_ACC=1 go test -v internal/network/resource_network_integration_test.go

- Schema Using Attribute Default For Non-Computed Attribute: Attribute
        "description" must be computed when using default. This is an issue with the
        provider and should be reported to the provider developers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maveonair any idea what's going on here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jonatas-lima is right, if we provide a default value then we must set the attribute Computed to true.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that's a bit counter-intuitive :)

Default: stringdefault.StaticString(""),
},

"remote": schema.StringAttribute{
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},

"project": schema.StringAttribute{
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
},
},

"config": schema.MapAttribute{
Optional: true,
Computed: true,
ElementType: types.StringType,
Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})),
},
},
}
}

func (r *NetworkIntegrationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
data := req.ProviderData
if data == nil {
return
}

provider, ok := data.(*provider_config.IncusProviderConfig)
if !ok {
resp.Diagnostics.Append(errors.NewProviderDataTypeError(req.ProviderData))
}

r.provider = provider
}

func (r NetworkIntegrationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan NetworkIntegrationModel

diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

remote := plan.Remote.ValueString()
project := plan.Project.ValueString()
server, err := r.provider.InstanceServer(remote, project, "")
if err != nil {
resp.Diagnostics.Append(errors.NewInstanceServerError(err))
return
}

config, diag := common.ToConfigMap(ctx, plan.Config)
resp.Diagnostics.Append(diag...)

networkIntegration := api.NetworkIntegrationsPost{
Name: plan.Name.ValueString(),
Type: plan.Type.ValueString(),
NetworkIntegrationPut: api.NetworkIntegrationPut{
Description: plan.Description.ValueString(),
Config: config,
},
}

err = server.CreateNetworkIntegration(networkIntegration)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to create network integration %q", networkIntegration.Name), err.Error())
return
}

diags = r.SyncState(ctx, &resp.State, server, plan)
resp.Diagnostics.Append(diags...)
}

func (r NetworkIntegrationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state NetworkIntegrationModel

diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

remote := state.Remote.ValueString()
project := state.Project.ValueString()
server, err := r.provider.InstanceServer(remote, project, "")
if err != nil {
resp.Diagnostics.Append(errors.NewInstanceServerError(err))
}

diags = r.SyncState(ctx, &resp.State, server, state)
resp.Diagnostics.Append(diags...)
}

func (r NetworkIntegrationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan NetworkIntegrationModel

diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

remote := plan.Remote.ValueString()
project := plan.Project.ValueString()
server, err := r.provider.InstanceServer(remote, project, "")
if err != nil {
resp.Diagnostics.Append(errors.NewInstanceServerError(err))
return
}

name := plan.Name.ValueString()

config, diag := common.ToConfigMap(ctx, plan.Config)
resp.Diagnostics.Append(diag...)
if resp.Diagnostics.HasError() {
return
}

_, etag, err := server.GetNetworkIntegration(name)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to retrieve existing network integration %q", name), err.Error())
return
}

networkIntegrationRequest := api.NetworkIntegrationPut{
Description: plan.Description.ValueString(),
Config: config,
}

err = server.UpdateNetworkIntegration(name, networkIntegrationRequest, etag)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to update network integration %q", name), err.Error())
}

diags = r.SyncState(ctx, &resp.State, server, plan)
resp.Diagnostics.Append(diags...)
}

func (r NetworkIntegrationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state NetworkIntegrationModel

diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

remote := state.Remote.ValueString()
project := state.Project.ValueString()
server, err := r.provider.InstanceServer(remote, project, "")
if err != nil {
resp.Diagnostics.Append(errors.NewInstanceServerError(err))
return
}

name := state.Name.ValueString()

err = server.DeleteNetworkIntegration(name)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove network integration %q", name), err.Error())
}
}

// SyncState fetches the server's current state for a network integration and updates
// the provided model. It then applies this updated model as the new state
// in Terraform.
func (r NetworkIntegrationResource) SyncState(ctx context.Context, tfState *tfsdk.State, server incus.InstanceServer, m NetworkIntegrationModel) diag.Diagnostics {
var respDiags diag.Diagnostics

networkIntegrationName := m.Name.ValueString()
networkIntegration, _, err := server.GetNetworkIntegration(networkIntegrationName)
if err != nil {
if errors.IsNotFoundError(err) {
tfState.RemoveResource(ctx)
return nil
}

respDiags.AddError(fmt.Sprintf("Failed to retrieve network integration %q", networkIntegrationName), err.Error())
return respDiags
}

config, diags := common.ToConfigMapType(ctx, common.ToNullableConfig(networkIntegration.Config), m.Config)
respDiags.Append(diags...)

m.Description = types.StringValue(networkIntegration.Description)
m.Type = types.StringValue(networkIntegration.Type)
m.Config = config

if respDiags.HasError() {
return respDiags
}

return tfState.Set(ctx, &m)
}
Loading
Loading