Plugin Development

Pacto uses an out-of-process plugin architecture for artifact generation. Plugins are standalone executables that receive a contract via JSON on stdin and write generated file descriptions to stdout.


Table of contents

This design is:

  • Language-agnostic — write plugins in Go, Python, Rust, Bash, or anything
  • Version-independent — plugins don’t link against Pacto libraries
  • Sandboxed — plugins receive a read-only view of the contract

How it works

sequenceDiagram
    participant User
    participant Pacto as pacto CLI
    participant Plugin as pacto-plugin-helm

    User->>Pacto: pacto generate helm pacto.yaml
    Pacto->>Pacto: Load and validate contract
    Pacto->>Plugin: Spawn process, write JSON to stdin
    Plugin->>Plugin: Read contract, generate files
    Plugin->>Pacto: Write JSON response to stdout
    Pacto->>Pacto: Write files to output directory
    Pacto->>User: Generated 3 file(s) using helm
  1. The user runs pacto generate <plugin-name> [path]
  2. Pacto loads and validates the contract
  3. Pacto finds the plugin binary (pacto-plugin-<name>)
  4. Pacto writes a GenerateRequest JSON to the plugin’s stdin
  5. The plugin reads the request, generates artifacts, and writes a GenerateResponse JSON to stdout
  6. Pacto reads the response and writes the generated files to disk

Plugin discovery

Pacto searches for plugin binaries in this order:

  1. $PATH — any binary named pacto-plugin-<name>
  2. ~/.config/pacto/plugins/ — user plugin directory

For example, pacto generate helm looks for:

  • pacto-plugin-helm in $PATH
  • ~/.config/pacto/plugins/pacto-plugin-helm

Protocol (v1)

Request (stdin)

Pacto writes a JSON object to the plugin’s stdin:

{
  "protocolVersion": "1",
  "contract": {
    "pactoVersion": "1.0",
    "service": {
      "name": "my-service",
      "version": "1.0.0"
    },
    "interfaces": [...],
    "runtime": {...},
    ...
  },
  "bundleDir": "/path/to/bundle",
  "outputDir": "/path/to/output",
  "options": {
    "namespace": "production"
  }
}
Field Type Description
protocolVersion string Always "1" for the current protocol
contract object The full parsed contract (same structure as pacto.yaml)
bundleDir string Absolute path to the bundle directory (read-only)
outputDir string Absolute path where output files should go
options object User-provided key-value options (from CLI flags)

Response (stdout)

The plugin writes a JSON object to stdout:

{
  "files": [
    {
      "path": "deployment.yaml",
      "content": "apiVersion: apps/v1\nkind: Deployment\n..."
    },
    {
      "path": "service.yaml",
      "content": "apiVersion: v1\nkind: Service\n..."
    }
  ],
  "message": "Generated Kubernetes manifests for my-service"
}
Field Type Description
files array List of generated files
files[].path string Relative path within the output directory
files[].content string File content
message string Optional message displayed to the user

Errors

If the plugin encounters an error, it should:

  1. Write a message to stderr
  2. Exit with a non-zero exit code

Pacto captures stderr and presents it to the user.


Example: Minimal plugin in Bash

#!/usr/bin/env bash
# pacto-plugin-readme — Generates a README from a Pacto contract

set -euo pipefail

# Read the full JSON request from stdin
REQUEST=$(cat)

# Extract fields using jq
NAME=$(echo "$REQUEST" | jq -r '.contract.service.name')
VERSION=$(echo "$REQUEST" | jq -r '.contract.service.version')
WORKLOAD=$(echo "$REQUEST" | jq -r '.contract.runtime.workload.type')
STATE=$(echo "$REQUEST" | jq -r '.contract.runtime.state.type')

# Generate a README
CONTENT="# ${NAME}

**Version:** ${VERSION}
**Workload:** ${WORKLOAD}
**State:** ${STATE}

This file was auto-generated by pacto-plugin-readme.
"

# Write the response JSON to stdout
jq -n \
  --arg path "README.md" \
  --arg content "$CONTENT" \
  --arg msg "Generated README for ${NAME}" \
  '{files: [{path: $path, content: $content}], message: $msg}'

Make it executable and place it in your $PATH:

chmod +x pacto-plugin-readme
mv pacto-plugin-readme /usr/local/bin/

# Use it
pacto generate readme my-service/pacto.yaml

Example: Plugin in Go

package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type Contract struct {
    Service struct {
        Name    string `json:"name"`
        Version string `json:"version"`
    } `json:"service"`
    Runtime struct {
        Workload struct {
            Type string `json:"type"`
        } `json:"workload"`
        State struct {
            Type string `json:"type"`
        } `json:"state"`
    } `json:"runtime"`
}

type Request struct {
    ProtocolVersion string   `json:"protocolVersion"`
    Contract        Contract `json:"contract"`
    BundleDir       string   `json:"bundleDir"`
    OutputDir       string   `json:"outputDir"`
    Options         map[string]any `json:"options"`
}

type File struct {
    Path    string `json:"path"`
    Content string `json:"content"`
}

type Response struct {
    Files   []File `json:"files"`
    Message string `json:"message,omitempty"`
}

func main() {
    var req Request
    if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
        fmt.Fprintf(os.Stderr, "failed to read request: %v\n", err)
        os.Exit(1)
    }

    content := fmt.Sprintf("# %s v%s\nWorkload: %s\nState: %s\n",
        req.Contract.Service.Name,
        req.Contract.Service.Version,
        req.Contract.Runtime.Workload.Type,
        req.Contract.Runtime.State.Type,
    )

    resp := Response{
        Files: []File{
            {Path: "README.md", Content: content},
        },
        Message: fmt.Sprintf("Generated README for %s", req.Contract.Service.Name),
    }

    json.NewEncoder(os.Stdout).Encode(resp)
}

Build and install:

go build -o pacto-plugin-readme .
mv pacto-plugin-readme /usr/local/bin/

Example: Plugin in Python

#!/usr/bin/env python3
"""pacto-plugin-env — Generates a .env.example from the contract's configuration schema."""

import json
import sys

def main():
    request = json.load(sys.stdin)
    contract = request["contract"]
    name = contract["service"]["name"]

    # Read the configuration schema from the bundle
    config = contract.get("configuration")
    if not config:
        response = {"files": [], "message": "No configuration section found"}
        json.dump(response, sys.stdout)
        return

    schema_path = f"{request['bundleDir']}/{config['schema']}"
    try:
        with open(schema_path) as f:
            schema = json.load(f)
    except FileNotFoundError:
        print(f"Schema file not found: {schema_path}", file=sys.stderr)
        sys.exit(1)

    # Generate .env.example from schema properties
    lines = [f"# Configuration for {name}", ""]
    for prop, details in schema.get("properties", {}).items():
        desc = details.get("description", "")
        comment = f"  # {desc}" if desc else ""
        lines.append(f"{prop.upper()}={comment}")

    content = "\n".join(lines) + "\n"

    response = {
        "files": [{"path": ".env.example", "content": content}],
        "message": f"Generated .env.example for {name}",
    }
    json.dump(response, sys.stdout)

if __name__ == "__main__":
    main()

Guidelines

  • Read only from bundleDir. Don’t access files outside the bundle.
  • Write only to stdout. Don’t write files directly; return them in the response. Pacto handles file creation.
  • Use stderr for errors. Anything on stderr is shown to the user on failure.
  • Exit non-zero on failure. Pacto checks the exit code.
  • Be deterministic. Given the same input, produce the same output.
  • Handle missing optional fields. Not all contracts have configuration, dependencies, scaling, etc.