🚀 Quick Start Guide¶
Build your first Terraform provider in Python! This guide walks you through creating a simple but functional provider in about 5 minutes.
📋 Prerequisites¶
Before starting, ensure you have:
- ✅ Python 3.11+ installed
- ✅ Pyvider installed (
pip install pyvideroruv add pyvider) - ✅ Basic understanding of Terraform concepts
- ✅ Familiarity with Python async/await
⚠️ Alpha Notice¶
Pyvider is in alpha. The APIs shown here may change before 1.0. This example is tested and working as of version 0.0.1000.
🎯 What We'll Build¶
We'll create a LocalFile Provider that can: - Create text files on your local filesystem - Read file contents as data sources - Update files when content changes - Delete files when resources are destroyed
📝 Step 1: Create the Provider¶
Create a new file called local_provider.py:
#!/usr/bin/env python3
"""Local File Provider - A simple Terraform provider for managing local files."""
from pathlib import Path
import hashlib
import attrs
from pyvider.providers import register_provider, BaseProvider, ProviderMetadata
from pyvider.resources import register_resource, BaseResource, ResourceContext
from pyvider.schema import s_provider, s_resource, a_str, a_num, PvsSchema
# ============================================
# PROVIDER DEFINITION
# ============================================
@register_provider("local")
class LocalProvider(BaseProvider):
"""Provider for managing local files."""
def __init__(self):
super().__init__(
metadata=ProviderMetadata(
name="local",
version="0.1.0",
protocol_version="6"
)
)
def _build_schema(self) -> PvsSchema:
"""Define provider schema (no configuration needed for this simple example)."""
return s_provider({})
# ============================================
# FILE RESOURCE
# ============================================
@attrs.define
class FileConfig:
"""File resource configuration."""
path: str
content: str
@attrs.define
class FileState:
"""File resource state."""
id: str
path: str
content: str
checksum: str
size: int
@register_resource("file")
class File(BaseResource):
"""Manages a local text file."""
config_class = FileConfig
state_class = FileState
@classmethod
def get_schema(cls) -> PvsSchema:
"""Define resource schema."""
return s_resource({
# Configuration attributes (user inputs)
"path": a_str(
required=True,
description="Path to the file"
),
"content": a_str(
required=True,
description="Content to write"
),
# Computed attributes (provider outputs)
"id": a_str(
computed=True,
description="File identifier"
),
"checksum": a_str(
computed=True,
description="SHA256 checksum"
),
"size": a_num(
computed=True,
description="File size in bytes"
),
})
async def _create_apply(self, ctx: ResourceContext) -> tuple[FileState | None, None]:
"""Create a new file."""
if not ctx.config:
return None, None
# Create file path
file_path = Path(ctx.config.path)
file_path.parent.mkdir(parents=True, exist_ok=True)
# Write content
file_path.write_text(ctx.config.content)
# Compute checksum
checksum = hashlib.sha256(ctx.config.content.encode()).hexdigest()
# Return state
return FileState(
id=str(file_path.absolute()),
path=str(file_path.absolute()),
content=ctx.config.content,
checksum=checksum,
size=len(ctx.config.content)
), None
async def read(self, ctx: ResourceContext) -> FileState | None:
"""Read current file state."""
if not ctx.state:
return None
file_path = Path(ctx.state.path)
if not file_path.exists():
return None # File deleted outside Terraform
content = file_path.read_text()
checksum = hashlib.sha256(content.encode()).hexdigest()
return FileState(
id=ctx.state.id,
path=ctx.state.path,
content=content,
checksum=checksum,
size=len(content)
)
async def _update_apply(self, ctx: ResourceContext) -> tuple[FileState | None, None]:
"""Update file content."""
if not ctx.config or not ctx.state:
return None, None
file_path = Path(ctx.state.path)
file_path.write_text(ctx.config.content)
checksum = hashlib.sha256(ctx.config.content.encode()).hexdigest()
return FileState(
id=ctx.state.id,
path=ctx.state.path,
content=ctx.config.content,
checksum=checksum,
size=len(ctx.config.content)
), None
async def _delete_apply(self, ctx: ResourceContext) -> None:
"""Delete the file."""
if not ctx.state:
return
file_path = Path(ctx.state.path)
if file_path.exists():
file_path.unlink()
# ============================================
# MAIN ENTRY POINT
# ============================================
if __name__ == "__main__":
from pyvider.cli import main
main()
🔧 Step 2: Create Terraform Configuration¶
Create main.tf in the same directory:
terraform {
required_providers {
local = {
source = "example.com/tutorial/local"
version = "0.1.0"
}
}
}
provider "local" {
# No configuration needed for this simple example
}
resource "local_file" "config" {
path = "managed_files/app.conf"
content = <<-EOT
# Application Configuration
app_name = "MyApp"
version = "1.0.0"
EOT
}
resource "local_file" "readme" {
path = "managed_files/README.md"
content = "# My Managed Files\n\nThis directory is managed by Terraform."
}
output "config_checksum" {
value = local_file.config.checksum
}
output "readme_size" {
value = local_file.readme.size
}
🚀 Step 3: Install and Run the Provider¶
Option A: Development Mode (Recommended for Testing)¶
The easiest way to test your provider during development is to use pyvider install:
# Make sure you're in a directory with your provider code
# and have pyvider installed in your virtual environment
# Install the provider for Terraform
pyvider install
# Now run Terraform normally - it will find your provider
terraform init
terraform plan
terraform apply
# Check the created files
ls -la managed_files/
cat managed_files/app.conf
cat managed_files/README.md
How This Works
pyvider install creates a wrapper script in Terraform's plugin directory that:
- Activates your virtual environment
- Runs your provider with the correct Python interpreter
- Allows Terraform to communicate with your provider via the plugin protocol
This is the recommended approach for development and testing.
Option B: Direct Execution (For Debugging)¶
To test the provider directly without Terraform:
# Set the Terraform magic cookie (Terraform normally does this)
export TF_PLUGIN_MAGIC_COOKIE="d602bf8f470bc67ca7faa0386276bbdd4330efaf76d1a219cb4e95f7aa66ead0"
# Run the provider
python local_provider.py provide
# In another terminal with the same environment variable,
# run Terraform commands
Direct Execution Limitations
This approach is useful for debugging but not recommended for normal development. The provider must be launched by Terraform to work correctly in production scenarios.
📊 Expected Output¶
After running terraform apply, you should see:
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
config_checksum = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
readme_size = 62
And the files will exist:
- managed_files/app.conf with your configuration
- managed_files/README.md with the README content
🎉 Congratulations!¶
You've just built your first Terraform provider in Python! You've:
- ✅ Created a provider with minimal configuration
- ✅ Implemented a full CRUD resource (create, read, update, delete)
- ✅ Computed attributes (checksum, size)
- ✅ Used it with real Terraform
🔍 What's Happening?¶
When you run your provider, Pyvider:
- Discovers Components: Finds all
@register_*decorators - Generates Schema: Converts Python types to Terraform schema
- Handles Protocol: Manages gRPC communication with Terraform
- Manages State: Tracks resource state between operations
- Provides Type Safety: Ensures data matches your
@attrs.defineclasses
📚 Understanding the Code¶
Provider Configuration¶
@register_provider("local")
class LocalProvider(BaseProvider):
"""Minimal provider - no configuration needed."""
The @register_provider decorator registers your provider with Pyvider. For this simple example, we don't need any provider-level configuration.
Resource Schema¶
@classmethod
def get_schema(cls) -> PvsSchema:
return s_resource({
"path": a_str(required=True, description="Path to the file"),
"content": a_str(required=True, description="Content to write"),
"checksum": a_str(computed=True, description="SHA256 checksum"),
})
The schema defines:
- User inputs: path and content (required)
- Provider outputs: checksum and size (computed)
Resource Context¶
async def _create_apply(self, ctx: ResourceContext) -> tuple[FileState | None, None]:
# Access configuration
file_path = Path(ctx.config.path)
content = ctx.config.content
# Create resource
file_path.write_text(content)
# Return state
return FileState(...), None
The ResourceContext provides:
- ctx.config - User configuration (FileConfig)
- ctx.state - Previous state (FileState) for updates
- ctx.planned_state - Planned state from terraform plan
- ctx.private_state - Encrypted private state (if needed)
🚦 Next Steps¶
Now that you understand the basics, explore:
Learn Core Concepts¶
- Architecture Overview - Understand how Pyvider works
- Component Model - Deep dive into components
- Schema System - Master schema definition
Build Real Providers¶
- Creating Providers - Comprehensive provider guide
- Creating Resources - Advanced resource patterns
- Testing Providers - Write comprehensive tests
- Best Practices - Production-ready patterns
See Examples¶
- Pyvider Components - 100+ working examples including:
- Resources: file_content, local_directory, timed_token
- Data Sources: env_variables, http_api, lens_jq
- Functions: String, numeric, and JQ operations
💡 Tips for Success¶
- Start Simple: Begin with basic resources before adding complexity
- Test Incrementally: Test each component as you develop
- Use Type Hints: Leverage Python's type system for safety
- Handle Errors Gracefully: Provide clear error messages
- Document Thoroughly: Add docstrings and schema descriptions
🔄 Making Changes¶
Try modifying the example:
Add Validation¶
async def _validate_config(self, config: FileConfig) -> list[str]:
"""Validate configuration."""
errors = []
if ".." in config.path:
errors.append("Path cannot contain '..'")
if len(config.content) > 1_000_000:
errors.append("Content too large (max 1MB)")
return errors
Add a Data Source¶
@register_data_source("file_info")
class FileInfo(BaseDataSource):
"""Read file metadata."""
@classmethod
def get_schema(cls):
return s_data_source({
"path": a_str(required=True),
"exists": a_bool(computed=True),
"size": a_num(computed=True),
})
async def read(self, ctx: ResourceContext):
file_path = Path(ctx.config.path)
return FileInfoState(
path=str(file_path),
exists=file_path.exists(),
size=file_path.stat().st_size if file_path.exists() else 0
)
🆘 Getting Help¶
If you run into issues:
- Check the Troubleshooting Guide
- Search GitHub Issues
- Ask in GitHub Discussions
- Review pyvider-components examples
🎊 Ready to build more? 🎊
Check out the Complete Provider Development Guide →