Terraform Provider in Rust (Day 36-37)

From terragrunt hooks to a Rust Terraform provider to replace hacky workarounds for OIDC realm configuration in Proxmox.

Terraform Provider in Rust (Day 36-37)
Photo by Tatiana Rodriguez / Unsplash

I was curious about Terraform providers and wanted to explore if we could write one in a different language other than Go (the language of choice was Rust for no particular reason). This also coincided with my wanting to see what using Claude Code was like.

So I chose to try something I was thinking about, and that was a "let's get rid of those Terragrunt hooks for OIDC configuration and use Terraform resources"

Yeah, another Proxmox provider exists that is more feature-rich compared to the Telmate Proxmox provider; however, I did this solely as an experimentation process.

The Framework First

Started by building tfplug (needs a better name), which is a framework that implements the Terraform Plugin Protocol v6.9. This handles all the gRPC communication and type conversions so providers can focus on their logic.

The framework handles:

  • Schema builders and Dynamic value handling
  • Resource and data source traits with full lifecycle support
  • Plan modifiers, validators, and defaults
  • Error handling with diagnostics

The framework exposes these core traits:

pub trait Provider: Send + Sync {
    fn type_name(&self) -> &str;

    async fn configure(
        &mut self,
        ctx: Context,
        request: ConfigureProviderRequest,
    ) -> ConfigureProviderResponse;

    fn resources(&self) -> HashMap<String, ResourceFactory>;
    fn data_sources(&self) -> HashMap<String, DataSourceFactory>;
  // Plus metadata, schema, validate, etc.
}

And resources traits like:

pub trait Resource: Send + Sync {
    fn type_name(&self) -> &str;

    async fn schema(
        &self,
        ctx: Context,
        request: ResourceSchemaRequest,
    ) -> ResourceSchemaResponse;

    async fn create(
        &self,
        ctx: Context,
        request: CreateResourceRequest,
    ) -> CreateResourceResponse;

    async fn read(
        &self,
        ctx: Context,
        request: ReadResourceRequest,
    ) -> ReadResourceResponse;

    async fn update(
        &self,
        ctx: Context,
        request: UpdateResourceRequest,
    ) -> UpdateResourceResponse;

    async fn delete(
        &self,
        ctx: Context,
        request: DeleteResourceRequest,
    ) -> DeleteResourceResponse;
   // Plus metadata, validate
}

The Proxmox Provider

With "framework" handling the protocol, the Proxmox provider just implements the traits:

pub struct ProxmoxProvider {
    client: Option<api::Client>,
}

impl Provider for ProxmoxProvider {
    fn type_name(&self) -> &str {
        "proxmox"
    }

    fn resources(&self) -> HashMap<String, ResourceFactory> {
        let mut resources = HashMap::new();

        // Register realm resource
        resources.insert(
            "proxmox_realm".to_string(),
            Box::new(|| Box::new(RealmResource::new())),
        );

        // Register VM resource
        resources.insert(
            "proxmox_qemu_vm".to_string(),
            Box::new(|| Box::new(QemuVmResource::new())),
        );

        resources
    }
}

Resources get provider data through a separate trait:

pub trait ResourceWithConfigure: Resource {
    fn configure(&mut self, ctx: Context, data: Arc<dyn Any + Send + Sync>);
}

and then in the realm resource

impl ResourceWithConfigure for RealmResource {
    fn configure(&mut self, _ctx: Context, data: Arc<dyn Any + Send + Sync>) {
        if let Some(provider_data) = data.downcast_ref::<ProxmoxProviderData>() {
            self.provider_data = Some(provider_data.clone());
        }
    }
}

Realm Resource

The schema definition shows all the OIDC fields we needed:

async fn schema(&self, _ctx: Context, _request: ResourceSchemaRequest) -> ResourceSchemaResponse {
    let schema = SchemaBuilder::new()
        .version(0)
        .description("Manages authentication realms in Proxmox VE")
        .attribute(
            AttributeBuilder::new("realm", AttributeType::String)
                .description("The realm identifier")
                .required()
                .build(),
        )
        .attribute(
            AttributeBuilder::new("type", AttributeType::String)
                .description("The authentication type")
                .required()
                .build(),
        )
        .attribute(
            AttributeBuilder::new("issuer_url", AttributeType::String)
                .optional()
                .build(),
        )
        // ... more OIDC fields
        .build();

    ResourceSchemaResponse { schema, diagnostics: vec![] }
}

The CRUD operations integrate with the Proxmox API:

async fn create(
      &self,
      _ctx: Context,
      request: CreateResourceRequest,
  ) -> CreateResourceResponse {
      let mut diagnostics = vec![];

      let provider_data = match &self.provider_data {
          Some(data) => data,
          None => {
              diagnostics.push(Diagnostic::error(
                  "Provider not configured",
                  "Provider data was not properly configured",
              ));
              return CreateResourceResponse {
                  new_state: request.planned_state,
                  private: vec![],
                  diagnostics,
              };
          }
      };

      // Extract realm configuration from request
      match self.extract_realm_config(&request.config) {
          Ok(realm_config) => {
              // Build and send create request to API
              let create_request = CreateRealmRequest {
                  realm: realm_config.realm.clone(),
                  realm_type: realm_config.realm_type.clone(),
                  issuer_url: realm_config.issuer_url.clone(),
                  client_id: realm_config.client_id.clone(),
                  client_key: realm_config.client_key.clone(),
                  // ... other fields
              };

              match provider_data.client.access().realms().create(&create_request).await {
                  Ok(()) => CreateResourceResponse {
                      new_state: request.planned_state,
                      private: vec![],
                      diagnostics,
                  },
                  Err(e) => {
                      diagnostics.push(Diagnostic::error(
                          "Failed to create realm",
                          format!("API error: {}", e),
                      ));
                      CreateResourceResponse {
                          new_state: request.planned_state,
                          private: vec![],
                          diagnostics,
                      }
                  }
              }
          }
          Err(diag) => {
              diagnostics.push(diag);
              CreateResourceResponse {
                  new_state: request.planned_state,
                  private: vec![],
                  diagnostics,
              }
          }
      }
  }

And now in Terraform:

resource "proxmox_realm" "authentik" {
  realm             = "authentik"
  type              = "openid"
  issuer_url        = "https://auth.example.com/application/o/proxmox/"
  client_id         = var.client_id
  client_key        = var.client_key
  username_claim    = "username"
  autocreate        = true
  default           = true
}

No more hooks! The realm resource handles all the OIDC configuration directly.

VM Resource

Since we were building a provider anyway, why stop at realms? The VM resource supports full QEMU configuration with a comprehensive schema:


async fn schema(
    &self,
    _ctx: Context,
    _request: ResourceSchemaRequest,
) -> ResourceSchemaResponse {
    let schema = SchemaBuilder::new()
        .version(0)
        .description("Manages QEMU/KVM virtual machines in Proxmox VE")
        .attribute(
            AttributeBuilder::new("node", AttributeType::String)
                .description("The name of the Proxmox node where the VM will be created")
                .required()
                .build(),
        )
        .attribute(
            AttributeBuilder::new("vmid", AttributeType::Number)
                .description("The VM identifier")
                .required()
                .build(),
        )
        .attribute(
            AttributeBuilder::new("name", AttributeType::String)
                .description("The VM name")
                .required()
                .build(),
        )
        .attribute(
            AttributeBuilder::new("cores", AttributeType::Number)
                .description("Number of CPU cores per socket")
                .optional()
                .build(),
        )
        .attribute(
            AttributeBuilder::new("sockets", AttributeType::Number)
                .description("Number of CPU sockets")
                .optional()
                .build(),
        )
        .attribute(
            AttributeBuilder::new("memory", AttributeType::Number)
                .description("Memory size in MB")
                .optional()
                .build(),
        )
        // ... more fields

        .build();

    ResourceSchemaResponse {
        schema,
        diagnostics: vec![],
    }
}
resource "proxmox_qemu_vm" "control_plane" {
  node      = "mjolnir"
  vmid      = 9001
  name      = "k8s-master"
  cores     = 2
  memory    = 4096
  
  scsi0     = "local-lvm:20,format=raw"
  net0      = "virtio,bridge=vmbr0,tag=30"
  
  ciuser    = "ubuntu"
  sshkeys   = file("~/.ssh/id_rsa.pub")
  ipconfig0 = "ip=dhcp"
  
  start     = true  # Auto-start after creation
}

Current State

Is it production-ready? No (and never will, please use something like Telmate or bpg/proxmox). Does it work for a homelab? Pretty much. (I have started dogfooding it slowly for the realms and VMs)

The code is available at https://github.com/mrdvince/surtr