Skip to content

Latest commit

 

History

History
803 lines (617 loc) · 21.2 KB

File metadata and controls

803 lines (617 loc) · 21.2 KB

Developer Guide: Using the github_app Library

This guide will walk you through integrating the github_app library into your Rust application to manage GitHub App integrations with GitOps support.


Table of Contents

  1. Prerequisites
  2. Installation
  3. GitHub App Setup
  4. Quick Start
  5. Configuration
  6. Authentication
  7. Webhook Handling
  8. GitOps Operations
  9. Complete Example
  10. Error Handling
  11. Best Practices
  12. Troubleshooting

Prerequisites

Before using this library, you need:

  • Rust: Version 1.70 or higher
  • GitHub App: A registered GitHub App with:
    • App ID
    • Installation ID
    • Private key (.pem file)
    • Webhook secret
    • Repository access permissions
  • Git: Installed on your system (for GitOps functionality)

Installation

Add the library to your Cargo.toml:

[dependencies]
github_app = { path = "../github_app" }  # or from git/crates.io when published
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }

GitHub App Setup

Step 1: Create a GitHub App

  1. Go to GitHub Settings → Developer settings → GitHub Apps → New GitHub App
  2. Fill in:
    • App name: Your app name
    • Homepage URL: Your app URL
    • Webhook URL: https://your-domain.com/github/webhook
    • Webhook secret: Generate a secure random string
  3. Set permissions:
    • Repository permissions:
      • Contents: Read & write (for GitOps)
      • Metadata: Read-only
  4. Subscribe to events:
    • Push
    • (Any other events you need)
  5. Click "Create GitHub App"

Step 2: Get Your Credentials

  1. App ID: Found on your app's settings page
  2. Private Key: Click "Generate a private key" and download the .pem file
  3. Installation ID: Install the app to your repository/organization, then get the installation ID from the URL: https://github.com/settings/installations/{installation_id}
  4. Webhook Secret: The secret you set during creation

Quick Start

Here's a minimal example to get you started:

use github_app::{GitHubAppConfig, GitHubTokenProvider, WebhookVerifier, GitHubGitOps};
use std::path::PathBuf;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Create configuration
    let config = GitHubAppConfig::new(
        123456,                                           // Your App ID
        789012,                                           // Your Installation ID
        std::fs::read_to_string("path/to/private-key.pem")?, // Private key
        std::env::var("GITHUB_WEBHOOK_SECRET")?,          // Webhook secret
        "owner/repo".to_string(),                         // Repository
        "main".to_string(),                               // Branch
        PathBuf::from("/tmp/my-repo-clone"),              // Clone path
        "manifests/**/*.yaml".to_string(),                // Manifest glob pattern
    );
    
    config.validate()?;
    
    // 2. Initialize components
    let token_provider = GitHubTokenProvider::new(config.clone());
    let webhook_verifier = WebhookVerifier::new(&config);
    let gitops = GitHubGitOps::new(config.clone(), GitHubTokenProvider::new(config.clone()));
    
    // 3. Initialize GitOps (clone repo)
    gitops.initialize().await?;
    
    println!("GitHub App integration ready!");
    
    Ok(())
}

Configuration

Environment Variables (Recommended)

Store sensitive data in environment variables:

export GITHUB_APP_ID=123456
export GITHUB_INSTALLATION_ID=789012
export GITHUB_PRIVATE_KEY_PATH=/path/to/private-key.pem
export GITHUB_WEBHOOK_SECRET=your-webhook-secret
export GITHUB_REPO=owner/repo
export GITHUB_BRANCH=main
export GIT_CLONE_PATH=/tmp/repo
export MANIFEST_GLOB="manifests/**/*.yaml"

Then load them in your code:

use github_app::GitHubAppConfig;
use std::path::PathBuf;

fn load_config() -> Result<GitHubAppConfig, Box<dyn std::error::Error>> {
    let config = GitHubAppConfig::new(
        std::env::var("GITHUB_APP_ID")?.parse()?,
        std::env::var("GITHUB_INSTALLATION_ID")?.parse()?,
        std::fs::read_to_string(std::env::var("GITHUB_PRIVATE_KEY_PATH")?)?,
        std::env::var("GITHUB_WEBHOOK_SECRET")?,
        std::env::var("GITHUB_REPO")?,
        std::env::var("GITHUB_BRANCH")?,
        PathBuf::from(std::env::var("GIT_CLONE_PATH")?),
        std::env::var("MANIFEST_GLOB")?,
    );
    
    config.validate()?;
    Ok(config)
}

Configuration Validation

Always validate your configuration:

match config.validate() {
    Ok(_) => println!("Configuration is valid"),
    Err(e) => eprintln!("Configuration error: {}", e),
}

Authentication

The library handles GitHub App authentication automatically using JWT and installation tokens.

Basic Usage

use github_app::GitHubTokenProvider;

let token_provider = GitHubTokenProvider::new(config);

// Get a token (automatically cached and refreshed)
let token = token_provider.get_token().await?;

println!("Installation token: {}", token);

How It Works

  1. JWT Creation: Creates a JWT signed with your private key (valid for 10 minutes)
  2. Token Request: Uses the JWT to request an installation access token from GitHub
  3. Caching: Caches the token until 5 minutes before expiry
  4. Auto-Refresh: Automatically refreshes the token when needed

Using Tokens with GitHub API

use reqwest::Client;

let token = token_provider.get_token().await?;
let client = Client::new();

let response = client
    .get("https://api.github.com/repos/owner/repo")
    .header("Authorization", format!("token {}", token))
    .header("User-Agent", "my-app")
    .send()
    .await?;

println!("Status: {}", response.status());

Webhook Handling

Setting Up a Webhook Endpoint

Here's an example using Axum (popular Rust web framework):

use axum::{
    extract::{State, Json},
    http::{StatusCode, HeaderMap},
    response::IntoResponse,
    routing::post,
    Router,
};
use github_app::{WebhookVerifier, WebhookEvent};
use std::sync::Arc;

struct AppState {
    verifier: WebhookVerifier,
}

async fn webhook_handler(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    body: axum::body::Bytes,
) -> impl IntoResponse {
    // Extract GitHub headers
    let event_type = match headers.get("X-GitHub-Event") {
        Some(v) => v.to_str().unwrap_or(""),
        None => return (StatusCode::BAD_REQUEST, "Missing X-GitHub-Event header"),
    };
    
    let signature = match headers.get("X-Hub-Signature-256") {
        Some(v) => v.to_str().unwrap_or(""),
        None => return (StatusCode::BAD_REQUEST, "Missing X-Hub-Signature-256 header"),
    };
    
    // Verify and parse event
    match state.verifier.parse_event(event_type, signature, &body) {
        Ok(event) => {
            // Handle the event
            if let Err(e) = handle_github_event(event).await {
                eprintln!("Error handling event: {}", e);
                return (StatusCode::INTERNAL_SERVER_ERROR, "Error processing event");
            }
            (StatusCode::OK, "Event processed")
        }
        Err(github_app::GitHubError::InvalidSignature) => {
            (StatusCode::UNAUTHORIZED, "Invalid signature")
        }
        Err(e) => {
            eprintln!("Error parsing webhook: {}", e);
            (StatusCode::INTERNAL_SERVER_ERROR, "Error parsing webhook")
        }
    }
}

async fn handle_github_event(event: WebhookEvent) -> Result<(), Box<dyn std::error::Error>> {
    match event {
        WebhookEvent::Push(push_event) => {
            println!("Push event received!");
            println!("  Repository: {}", push_event.repository.full_name);
            println!("  Ref: {}", push_event.git_ref);
            
            if let Some(branch) = push_event.branch() {
                println!("  Branch: {}", branch);
            }
            
            if let Some(sha) = push_event.commit_sha() {
                println!("  Commit SHA: {}", sha);
            }
        }
        WebhookEvent::Ping => {
            println!("Ping received - webhook is working!");
        }
        WebhookEvent::Unknown(event_type) => {
            println!("Unknown event type: {}", event_type);
        }
    }
    
    Ok(())
}

#[tokio::main]
async fn main() {
    let config = load_config().unwrap();
    let verifier = WebhookVerifier::new(&config);
    
    let state = Arc::new(AppState { verifier });
    
    let app = Router::new()
        .route("/github/webhook", post(webhook_handler))
        .with_state(state);
    
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    
    println!("Webhook server running on http://0.0.0.0:3000");
    axum::serve(listener, app).await.unwrap();
}

Manual Signature Verification

If you need to verify signatures separately:

use github_app::WebhookVerifier;

let verifier = WebhookVerifier::new(&config);

match verifier.verify_signature(signature, body) {
    Ok(_) => println!("Signature is valid!"),
    Err(_) => println!("Invalid signature - webhook may be spoofed!"),
}

GitOps Operations

Basic GitOps Workflow

use github_app::{GitHubGitOps, GitHubTokenProvider};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct MyManifest {
    name: String,
    version: String,
    replicas: u32,
}

async fn gitops_example(config: GitHubAppConfig) -> Result<(), Box<dyn std::error::Error>> {
    let token_provider = GitHubTokenProvider::new(config.clone());
    let gitops = GitHubGitOps::new(config, token_provider);
    
    // 1. Initialize (clone repository if not exists)
    println!("Initializing repository...");
    gitops.initialize().await?;
    
    // 2. Sync (pull latest changes)
    println!("Syncing repository...");
    gitops.sync().await?;
    
    // 3. Load manifests
    println!("Loading manifests...");
    let manifests: Vec<MyManifest> = gitops.load_all_manifests()?;
    
    // 4. Process manifests
    for manifest in manifests {
        println!("Processing: {} v{} (replicas: {})", 
                 manifest.name, manifest.version, manifest.replicas);
    }
    
    Ok(())
}

Responding to Webhook Events

Typical pattern: sync repo when push events occur on the main branch:

use github_app::{WebhookEvent, GitHubGitOps};

async fn handle_webhook_with_gitops(
    event: WebhookEvent,
    gitops: &GitHubGitOps,
    config: &GitHubAppConfig,
) -> Result<(), github_app::GitHubError> {
    match event {
        WebhookEvent::Push(push_event) => {
            // Only sync if push is to the tracked branch
            if push_event.branch() == Some(config.branch.clone()) {
                println!("Push to {} - syncing repository", config.branch);
                gitops.sync().await?;
                
                // Reload and process manifests
                let manifests: Vec<MyManifest> = gitops.load_all_manifests()?;
                process_manifests(manifests).await?;
            }
        }
        _ => {}
    }
    
    Ok(())
}

Custom Manifest Types

Define your own manifest structures:

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct DeploymentManifest {
    api_version: String,
    kind: String,
    metadata: Metadata,
    spec: DeploymentSpec,
}

#[derive(Debug, Deserialize)]
struct Metadata {
    name: String,
    namespace: Option<String>,
}

#[derive(Debug, Deserialize)]
struct DeploymentSpec {
    replicas: u32,
    image: String,
    port: u16,
}

// Load your custom manifests
let deployments: Vec<DeploymentManifest> = gitops.load_all_manifests()?;

Error Handling in GitOps

match gitops.sync().await {
    Ok(_) => println!("Repository synced successfully"),
    Err(github_app::GitHubError::Git(msg)) => {
        eprintln!("Git error: {}", msg);
        // Handle git-specific errors (e.g., merge conflicts)
    }
    Err(github_app::GitHubError::Http(e)) => {
        eprintln!("Network error: {}", e);
        // Handle network issues
    }
    Err(e) => {
        eprintln!("Unexpected error: {}", e);
    }
}

Complete Example

Here's a full working example of a GitOps broker:

use github_app::{
    GitHubAppConfig, GitHubTokenProvider, WebhookVerifier,
    GitHubGitOps, WebhookEvent, GitHubError
};
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::RwLock;

#[derive(Debug, Deserialize)]
struct AppManifest {
    name: String,
    version: String,
    enabled: bool,
}

struct GitOpsBroker {
    config: GitHubAppConfig,
    token_provider: Arc<GitHubTokenProvider>,
    webhook_verifier: WebhookVerifier,
    gitops: Arc<GitHubGitOps>,
    state: Arc<RwLock<BrokerState>>,
}

struct BrokerState {
    manifests: Vec<AppManifest>,
    last_sync: Option<std::time::SystemTime>,
}

impl GitOpsBroker {
    async fn new(config: GitHubAppConfig) -> Result<Self, GitHubError> {
        config.validate()?;
        
        let token_provider = Arc::new(GitHubTokenProvider::new(config.clone()));
        let webhook_verifier = WebhookVerifier::new(&config);
        let gitops = Arc::new(GitHubGitOps::new(
            config.clone(),
            GitHubTokenProvider::new(config.clone())
        ));
        
        // Initialize repository
        println!("Initializing repository...");
        gitops.initialize().await?;
        
        let state = Arc::new(RwLock::new(BrokerState {
            manifests: Vec::new(),
            last_sync: None,
        }));
        
        let broker = Self {
            config,
            token_provider,
            webhook_verifier,
            gitops,
            state,
        };
        
        // Initial sync
        broker.sync_and_reconcile().await?;
        
        Ok(broker)
    }
    
    async fn handle_webhook(
        &self,
        event_type: &str,
        signature: &str,
        body: &[u8],
    ) -> Result<(), GitHubError> {
        // Verify and parse webhook
        let event = self.webhook_verifier.parse_event(event_type, signature, body)?;
        
        match event {
            WebhookEvent::Push(push_event) => {
                println!("Push event: {} to {}", 
                         push_event.commit_sha().unwrap_or_default(),
                         push_event.branch().unwrap_or_default());
                
                // Only sync if push is to our tracked branch
                if push_event.branch() == Some(self.config.branch.clone()) {
                    self.sync_and_reconcile().await?;
                }
            }
            WebhookEvent::Ping => {
                println!("Ping received - webhook configured correctly");
            }
            WebhookEvent::Unknown(event_type) => {
                println!("Ignoring unknown event: {}", event_type);
            }
        }
        
        Ok(())
    }
    
    async fn sync_and_reconcile(&self) -> Result<(), GitHubError> {
        println!("Syncing repository...");
        self.gitops.sync().await?;
        
        println!("Loading manifests...");
        let manifests: Vec<AppManifest> = self.gitops.load_all_manifests()?;
        
        println!("Loaded {} manifests", manifests.len());
        
        // Update state
        let mut state = self.state.write().await;
        state.manifests = manifests;
        state.last_sync = Some(std::time::SystemTime::now());
        
        // Reconcile (apply changes)
        self.reconcile(&state.manifests).await?;
        
        Ok(())
    }
    
    async fn reconcile(&self, manifests: &[AppManifest]) -> Result<(), GitHubError> {
        println!("Reconciling {} manifests...", manifests.len());
        
        for manifest in manifests {
            if manifest.enabled {
                println!("  ✓ {} v{} - enabled", manifest.name, manifest.version);
                // Deploy or update application
            } else {
                println!("  ✗ {} v{} - disabled", manifest.name, manifest.version);
                // Remove or disable application
            }
        }
        
        Ok(())
    }
    
    async fn get_state(&self) -> BrokerState {
        self.state.read().await.clone()
    }
}

// Make BrokerState cloneable
impl Clone for BrokerState {
    fn clone(&self) -> Self {
        Self {
            manifests: self.manifests.clone(),
            last_sync: self.last_sync,
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Load configuration
    let config = GitHubAppConfig::new(
        std::env::var("GITHUB_APP_ID")?.parse()?,
        std::env::var("GITHUB_INSTALLATION_ID")?.parse()?,
        std::fs::read_to_string(std::env::var("GITHUB_PRIVATE_KEY_PATH")?)?,
        std::env::var("GITHUB_WEBHOOK_SECRET")?,
        std::env::var("GITHUB_REPO")?,
        std::env::var("GITHUB_BRANCH")?,
        std::path::PathBuf::from(std::env::var("GIT_CLONE_PATH")?),
        std::env::var("MANIFEST_GLOB")?,
    );
    
    // Initialize broker
    let broker = Arc::new(GitOpsBroker::new(config).await?);
    
    println!("GitOps Broker initialized successfully!");
    
    // Start webhook server (example with Axum)
    // ... (see webhook handling section)
    
    Ok(())
}

Error Handling

The library uses a unified GitHubError type for all errors:

use github_app::GitHubError;

async fn handle_operation() -> Result<(), GitHubError> {
    // Your code here
    Ok(())
}

// Pattern match on specific errors
match handle_operation().await {
    Ok(_) => println!("Success!"),
    Err(GitHubError::InvalidSignature) => {
        eprintln!("Webhook signature verification failed");
    }
    Err(GitHubError::Git(msg)) => {
        eprintln!("Git operation failed: {}", msg);
    }
    Err(GitHubError::Http(e)) => {
        eprintln!("HTTP request failed: {}", e);
    }
    Err(GitHubError::Yaml(e)) => {
        eprintln!("YAML parsing failed: {}", e);
    }
    Err(e) => {
        eprintln!("Unexpected error: {}", e);
    }
}

Best Practices

1. Security

  • Never commit private keys or secrets to version control
  • Use environment variables or secret management systems
  • Validate webhook signatures before processing events
  • Use HTTPS for webhook endpoints in production

2. Token Management

  • Let the library handle token caching - don't cache tokens yourself
  • Share one GitHubTokenProvider instance across your application
  • The library automatically refreshes tokens before expiry

3. GitOps

  • Call initialize() once at startup
  • Call sync() when you receive webhook push events
  • Handle git errors gracefully (network issues, merge conflicts)
  • Use specific manifest types with proper validation

4. Error Handling

  • Always handle GitHubError::InvalidSignature for webhooks (return 401)
  • Log all errors for debugging
  • Implement retry logic for transient network errors

5. Performance

  • Share instances across async tasks using Arc
  • Use tokio::sync::RwLock for shared state
  • Process webhook events asynchronously
  • Consider rate limiting for GitOps sync operations

Troubleshooting

"Invalid signature" errors

Cause: Webhook secret mismatch or request tampering

Solution:

  1. Verify your webhook secret matches GitHub App settings
  2. Ensure you're passing the raw request body (not parsed JSON)
  3. Check header name: X-Hub-Signature-256 (not X-Hub-Signature)
// Correct: use raw bytes
let body = request.body().as_bytes();
verifier.parse_event(event_type, signature, body)?;

// Incorrect: don't parse first
let parsed = serde_json::from_slice(body)?; // ❌ Don't do this

"JWT error" when getting tokens

Cause: Invalid private key or incorrect App ID

Solution:

  1. Verify private key format (should be PEM format)
  2. Check App ID is correct
  3. Ensure private key file is readable
# Private key should look like this:
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
...
-----END RSA PRIVATE KEY-----

"Git error" during sync

Cause: Network issues, authentication problems, or local repo corruption

Solution:

  1. Check network connectivity to GitHub
  2. Verify installation token permissions
  3. Delete clone directory and re-initialize
  4. Check git is installed: which git

"YAML parsing error"

Cause: Invalid YAML in manifest files

Solution:

  1. Validate YAML syntax: yamllint manifests/
  2. Ensure manifest structure matches your struct
  3. Check for required fields
#[derive(Debug, Deserialize)]
struct Manifest {
    name: String,              // Required
    version: String,           // Required
    description: Option<String>, // Optional
}

Performance Issues

If GitOps operations are slow:

  1. Reduce manifest glob scope: Use specific patterns like manifests/*.yaml instead of **/*.yaml
  2. Limit sync frequency: Don't sync on every webhook event
  3. Use shallow clones: Modify gitops.rs to use --depth 1

Additional Resources


Support

For issues, questions, or contributions, please open an issue in the repository.


License

MIT