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.
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