Rust MLOps: Fine-Tune an LLM on Honeypot Data

Build a Rust MLOps pipeline that trains an LLM on multi-protocol honeypot data and gates deployment automatically.

Introduction

30 Second Summary

A multi-service honeypot collects attacker data across SSH, HTTP, FTP, PostgreSQL, and Modbus services. That data is only valuable if a model can learn from it automatically, getting more convincing with every attack session without manual retraining.

In this project, you will build a Continuous Training pipeline entirely in Rust that ingests honeypot logs and external data sources, preprocesses them into training data, trains a custom LLM through Ollama, evaluates response quality, and gates deployment with automated checks.

What You'll Build

A Rust MLOps pipeline that pulls attack logs from SSH, HTTP, FTP, PostgreSQL, and Modbus honeypots plus external sources, sanitizes and formats them into training data, creates a custom Ollama model, evaluates it against known attack patterns, and gates deployment based on quality thresholds.

By the end of this project, you'll have:

  • A Rust data ingestion pipeline that reads session logs from five honeypot protocols plus external log sources and normalizes them into a single structured format.
  • A training step that formats preprocessed data into an Ollama Modelfile and creates a custom model tuned for realistic honeypot responses.
  • An evaluation and deployment gate that tests model quality against known attack patterns and blocks deployment of underperforming models.
  • A Secret Mission to add automated external data ingestion from threat intelligence feeds and syslog sources so the model trains on real-world data.

Are there any prerequisites?

This project requires Rust (latest stable) and Ollama installed on Linux or macOS. No GPU is required. The instructions assume basic familiarity with the terminal and a text editor.

Set Up Your MLOps Environment

Set Up Your Rust Environment

Your Continuous Training pipeline needs to read log files, call an LLM over HTTP, serialize data as JSON, and hash sensitive values. Rust gives you a single compiled binary with no runtime dependencies, and Tokio provides the async runtime for non-blocking HTTP calls to Ollama.

In this step, you will install Rust, create a new Cargo project with all the dependencies your pipeline needs, and verify that Ollama is serving a model for inference.

In this step, get ready to:
  • Install Rust and create a new Cargo project.
  • Add Tokio, reqwest, serde, and other pipeline dependencies.
  • Install Ollama and pull the Mistral 7B model for inference.
Install Rust and create the project
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check if Rust is already installed by running this command:
rustc --version

I see a version number

Rust is ready. Move on to the next substep.

Command not found

  • Install Rust by running this command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  • The installer will ask you to proceed with the default installation. Press 1 then Enter.
  • After installation completes, load the new PATH by running:
source "$HOME/.cargo/env"
  • Verify the installation by running:
rustc --version

You should see a version number like rustc 1.82.0.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new Cargo project by running this command:
cargo new honeypot-pipeline
cd honeypot-pipeline

What does cargo new do?

Cargo is Rust's build tool and package manager. cargo new honeypot-pipeline creates a new project directory with a Cargo.toml (dependencies and metadata) and a src/main.rs starter file.

  • Confirm the project structure by running:
ls

You should see Cargo.toml and src in the output.

Add pipeline dependencies

Your pipeline needs async HTTP calls to Ollama, JSON serialization for log parsing and training data, SHA-256 hashing for IP sanitization, and file I/O for reading honeypot logs.

  • Open Cargo.toml in your text editor and replace its contents with the following:
[package]
name = "honeypot-pipeline"
version = "0.1.0"
edition = "2021"

[dependencies]
# Async runtime for non-blocking HTTP calls to Ollama
tokio = { version = "1", features = ["full"] }
# HTTP client for calling the Ollama API
reqwest = { version = "0.12", features = ["json"] }
# JSON serialization for logs, training data, and API payloads
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Timestamps for log entries and training metadata
chrono = { version = "0.4", features = ["serde"] }
# SHA-256 hashing for IP sanitization
sha2 = "0.10"
# Unique pipeline run IDs
uuid = { version = "1", features = ["v4"] }
# Structured logging for pipeline steps
tracing = "0.1"
tracing-subscriber = "0.3"
# Glob pattern matching for reading log directories
glob = "0.3"

What do these crates do?

  • tokio is the async runtime that lets your pipeline make non-blocking HTTP calls to Ollama while processing data.
  • reqwest sends HTTP requests to the Ollama API for model creation and inference testing.
  • serde and serde_json serialize session data to JSON for log parsing, training data formatting, and API communication.
  • sha2 provides SHA-256 hashing to sanitize attacker IP addresses before they enter the training data.
  • glob finds log files across multiple protocol directories using wildcard patterns like logs/**/*.json.
  • Save Cargo.toml.
  • Download and compile all dependencies by running:
cargo build

The first build downloads and compiles all crates. This takes 1-2 minutes. You should see Finished at the end.

Build failed with a network error?

  • Make sure you have an active internet connection. Cargo downloads crates from crates.io on the first build.
  • If you see a version resolution error, check that your Cargo.toml matches the code above exactly. A missing comma or mismatched bracket breaks the TOML parser.

Help me fix a cargo build error in my honeypot pipeline project.

Install Ollama and pull the model

Your pipeline trains a model through Ollama by creating custom Modelfiles from preprocessed honeypot data. Ollama runs locally and exposes an HTTP API your Rust code calls for model creation and evaluation.

  • Check if Ollama is installed by running:
ollama --version

I see a version number

Ollama is ready. Skip ahead to pulling the model.

Command not found

  • Install Ollama by running this command:
curl -fsSL https://ollama.com/install.sh | sh

You should see Install complete at the end.

  • Pull the Mistral 7B model by running this command:
ollama pull mistral

This downloads ~4GB of model weights. It only needs to happen once.

  • Verify the model works by running a quick test:
ollama run mistral "whoami"

You should see the model respond with text. The exact response may vary.

Ollama not responding?

  • Make sure the Ollama server is running. In a separate terminal, run ollama serve to start it.
  • If the model download stalls, check your internet connection and try again. Ollama resumes partial downloads.

Help me get Ollama running on my machine.

Your Rust project compiles and Ollama serves the LLM. Next up, you will generate multi-protocol sample honeypot data that the pipeline will ingest and preprocess.

Generate Sample Honeypot Data and Initialize ZenML

Generate Sample Honeypot Data

Your Rust environment is ready and Ollama is serving the Mistral model. Every CT pipeline starts with data. Before your pipeline can train anything, it needs realistic honeypot session logs to learn from.

In this step, you will build a sample data generator that produces multi-protocol honeypot logs across SSH, HTTP, FTP, PostgreSQL, and Modbus. Then you will write the ingestion module that reads those logs and normalizes them into a single structured format.

In this step, get ready to:
  • Build a Rust binary that generates 50 sample honeypot session logs across all five protocols.
  • Write the data ingestion module that reads and normalizes multi-protocol JSON logs.
Build the sample data generator

Your pipeline needs training data in the same format a real multi-service honeypot would produce. Each log file represents one attacker session tagged with the protocol used, a hashed source IP, timestamps, and command-response pairs.

  • Create a new file called src/generate_data.rs in your honeypot-pipeline project folder and add the following code:
//! Generate sample multi-protocol honeypot session logs for pipeline testing.

use chrono::{Duration, Utc};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::fs;
use uuid::Uuid;

/// A single command-response interaction in a session.
#[derive(Serialize)]
struct Interaction {
    timestamp: String,
    input: String,
    output: String,
}

/// A complete attacker session log.
#[derive(Serialize)]
struct SessionLog {
    session_id: String,
    service: String,
    source_ip_hash: String,
    connected_at: String,
    disconnected_at: String,
    interactions: Vec<Interaction>,
}

/// Hash an IP address with SHA-256 so raw IPs never appear in logs.
fn hash_ip(ip: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(ip.as_bytes());
    let result = hasher.finalize();
    format!("ip_{:x}{:x}{:x}{:x}", result[0], result[1], result[2], result[3])
}

/// Command-response pairs for each protocol.
fn ssh_commands() -> Vec<(&'static str, &'static str)> {
    vec![
        ("whoami", "root"),
        ("pwd", "/root"),
        ("id", "uid=0(root) gid=0(root) groups=0(root)"),
        ("uname -a", "Linux srv01 5.4.0-42 x86_64 GNU/Linux"),
        ("cat /etc/passwd", "root:x:0:0:root:/root:/bin/bash"),
        ("ls -la /root", "total 32\ndrwx------ 4 root root 4096 .bashrc"),
        ("ifconfig", "eth0: inet 10.0.2.15 netmask 255.255.255.0"),
        ("netstat -tulnp", "tcp 0 0 0.0.0.0:22 LISTEN 1234/sshd"),
        ("cat /etc/shadow", "root:$6$rounds=656000$salt$hash:18000:::"),
        ("hostname", "srv01"),
    ]
}

fn http_commands() -> Vec<(&'static str, &'static str)> {
    vec![
        ("GET /", "<html><body>Welcome to Apache</body></html>"),
        ("GET /admin", "401 Unauthorized"),
        ("POST /login", "{\"status\":\"invalid_credentials\"}"),
        ("GET /wp-admin", "<html><body>WordPress Admin</body></html>"),
        ("GET /.env", "APP_KEY=base64:fake_key_here"),
        ("GET /phpmyadmin", "phpMyAdmin 5.1.1"),
    ]
}

fn ftp_commands() -> Vec<(&'static str, &'static str)> {
    vec![
        ("USER admin", "331 Password required"),
        ("PASS admin123", "230 Login successful"),
        ("LIST", "drwxr-xr-x 2 root root 4096 uploads"),
        ("CWD /etc", "250 Directory changed"),
        ("RETR /etc/passwd", "root:x:0:0:root:/root:/bin/bash"),
    ]
}

fn postgres_commands() -> Vec<(&'static str, &'static str)> {
    vec![
        ("SELECT version()", "PostgreSQL 14.2 on x86_64-linux"),
        ("SELECT current_user", "postgres"),
        ("\\dt", "public | users | table | postgres"),
        ("SELECT * FROM users LIMIT 1", "1 | admin | admin@local"),
        ("SELECT pg_read_file('/etc/passwd')", "ERROR: permission denied"),
    ]
}

fn modbus_commands() -> Vec<(&'static str, &'static str)> {
    vec![
        ("READ_COILS 0 10", "[1,0,1,1,0,0,1,0,1,1]"),
        ("READ_HOLDING_REGISTERS 0 5", "[2048,1024,512,256,128]"),
        ("WRITE_SINGLE_COIL 5 1", "OK: Coil 5 set to ON"),
        ("READ_INPUT_REGISTERS 0 3", "[72,65,90]"),
        ("WRITE_MULTIPLE_REGISTERS 0 [100,200]", "OK: 2 registers written"),
    ]
}

pub fn generate_sample_logs(output_dir: &str, num_sessions: usize) {
    // Create output directory
    fs::create_dir_all(output_dir).expect("Failed to create output directory");

    let protocols: Vec<(&str, Vec<(&str, &str)>)> = vec![
        ("ssh", ssh_commands()),
        ("http", http_commands()),
        ("ftp", ftp_commands()),
        ("postgres", postgres_commands()),
        ("modbus", modbus_commands()),
    ];

    let base_time = Utc::now();
    let mut generated = 0;

    for i in 0..num_sessions {
        // Cycle through protocols
        let (service, commands) = &protocols[i % protocols.len()];
        let fake_ip = format!("192.168.{}.{}", i / 256, i % 256);

        // Pick 3-5 commands from this protocol's pool
        let num_cmds = 3 + (i % 3);
        let selected: Vec<_> = commands.iter().cycle().take(num_cmds).collect();

        let start = base_time + Duration::minutes(i as i64 * 10);
        let mut interactions = Vec::new();
        let mut cmd_time = start;

        for (input, output) in &selected {
            cmd_time = cmd_time + Duration::seconds(2);
            interactions.push(Interaction {
                timestamp: cmd_time.to_rfc3339(),
                input: input.to_string(),
                output: output.to_string(),
            });
        }

        let end_time = cmd_time + Duration::seconds(10);
        let session = SessionLog {
            session_id: Uuid::new_v4().to_string(),
            service: service.to_string(),
            source_ip_hash: hash_ip(&fake_ip),
            connected_at: start.to_rfc3339(),
            disconnected_at: end_time.to_rfc3339(),
            interactions,
        };

        // Write one JSON file per session
        let filename = format!(
            "{}/{}_{}.json",
            output_dir, service, session.session_id
        );
        let json = serde_json::to_string_pretty(&session)
            .expect("Failed to serialize session");
        fs::write(&filename, json).expect("Failed to write log file");
        generated += 1;
    }

    println!("Generated {} sample session logs in {}/", generated, output_dir);
}

What does this code do?

  • The generator produces sessions across all five protocols: SSH, HTTP, FTP, PostgreSQL, and Modbus.
  • Each protocol has its own set of realistic command-response pairs. SSH sessions contain Linux terminal commands, HTTP sessions contain web requests, and Modbus sessions contain industrial register reads/writes.
  • hash_ip() hashes every IP address with SHA-256 before writing it to the log. The pipeline never sees raw IPs.
  • Each session is saved as a separate JSON file named with the protocol and session ID (e.g. ssh_a1b2c3d4.json).
  • Save src/generate_data.rs.
  • Now wire the generator into your binary. Open src/main.rs and replace its contents with:
//! Honeypot CT Pipeline — data generation entry point.

mod generate_data;

fn main() {
    // Generate 50 sample sessions across all five protocols
    generate_data::generate_sample_logs("./sample_logs", 50);
}
  • Save src/main.rs.
  • Run the generator by running:
cargo run

You should see Generated 50 sample session logs in ./sample_logs/ printed to your terminal.

  • Confirm the log files were created by running:
ls sample_logs/ | head

You should see files from multiple protocols like ssh_*.json, http_*.json, ftp_*.json, postgres_*.json, and modbus_*.json.

No files in sample_logs?

  • Make sure you saved src/generate_data.rs and src/main.rs before running.
  • If you see a compile error about Duration, check that chrono is in your Cargo.toml dependencies.

Why can't I generate sample log files?

Your sample data is ready across all five protocols. Next up, you will build the data ingestion and preprocessing modules that read these logs, sanitize them, and format them into training data.

Build the Data Ingestion and Preprocessing Steps

Build the Data Ingestion and Preprocessing Modules

Your sample data is generated across all five protocols. Now you need a way to turn those raw session files into something a model can learn from.

In this step, you will write two Rust modules that form the front half of your pipeline. The first module ingests all JSON log files and parses them into a normalized internal format. The second module sanitizes sensitive IPs, formats the data into instruction-response pairs, and splits it into training and evaluation sets.

In this step, get ready to:
  • Write the log ingestion module that reads multi-protocol JSON logs into normalized structs.
  • Write the preprocessing module that sanitizes IPs, formats instruction data, and splits 80/20.
Write the log ingestion module

Each honeypot session log is a JSON file with a session ID, service tag, hashed IP, and a list of input-output interaction pairs. Your ingestion module reads every JSON file from the log directory and deserializes them into Rust structs.

  • Create a new file called src/ingest.rs in your honeypot-pipeline project folder and add the following code:
//! Log ingestion module for multi-protocol honeypot session logs.

use glob::glob;
use serde::Deserialize;
use std::fs;

/// A single interaction from a session log file.
#[derive(Debug, Deserialize, Clone)]
pub struct RawInteraction {
    pub timestamp: String,
    pub input: String,
    pub output: String,
}

/// A complete session log as stored on disk.
#[derive(Debug, Deserialize, Clone)]
pub struct RawSession {
    pub session_id: String,
    pub service: String,
    pub source_ip_hash: String,
    pub connected_at: String,
    pub disconnected_at: String,
    pub interactions: Vec<RawInteraction>,
}

/// Read all JSON session logs from the given directory.
pub fn ingest_logs(log_dir: &str) -> Result<Vec<RawSession>, Box<dyn std::error::Error>> {
    let pattern = format!("{}/**/*.json", log_dir);
    let files: Vec<_> = glob(&pattern)?
        .filter_map(|entry| entry.ok())
        .collect();

    if files.is_empty() {
        return Err(format!("No .json files found in {}", log_dir).into());
    }

    let mut sessions = Vec::new();
    for path in &files {
        let content = fs::read_to_string(path)?;
        let session: RawSession = serde_json::from_str(&content)?;
        sessions.push(session);
    }

    let total_pairs: usize = sessions.iter()
        .map(|s| s.interactions.len())
        .sum();

    println!(
        "Ingested {} sessions, {} interaction pairs",
        sessions.len(),
        total_pairs
    );

    Ok(sessions)
}

What does this code do?

  • RawSession and RawInteraction mirror the JSON structure your data generator produces. Serde deserializes each file directly into these structs.
  • The glob pattern {}/**/*.json recursively finds all JSON files, so you can organize logs into subdirectories by protocol if needed.
  • The function fails fast if zero files are found, preventing the pipeline from silently training on empty data.
  • Save src/ingest.rs.

You won't see a change yet. The next chunk adds the preprocessing module.

Write the preprocessing module

The preprocessing module takes raw sessions, verifies all IPs are already hashed, formats each interaction into an instruction-response training pair, and splits the data 80/20 for training and evaluation.

  • Create a new file called src/preprocess.rs in your project folder and add the following code:
//! Preprocessing module: format training data and split train/eval.

use crate::ingest::RawSession;
use serde::Serialize;

/// A single training example in instruction-response format.
#[derive(Debug, Serialize, Clone)]
pub struct TrainingExample {
    /// Which protocol this example came from
    pub service: String,
    /// The instruction template with the attacker's input
    pub instruction: String,
    /// The expected response the model should learn
    pub response: String,
}

/// Result of preprocessing: training set and evaluation set.
pub struct PreprocessedData {
    pub train: Vec<TrainingExample>,
    pub eval: Vec<TrainingExample>,
}

/// Format raw sessions into training examples and split 80/20.
pub fn preprocess(
    sessions: &[RawSession],
) -> Result<PreprocessedData, Box<dyn std::error::Error>> {
    let mut examples = Vec::new();

    for session in sessions {
        // Build a service-specific instruction prefix
        let service_context = match session.service.as_str() {
            "ssh" => "You are a Linux terminal. Execute this command and return only the output.",
            "http" => "You are an Apache web server. Return the HTTP response body for this request.",
            "ftp" => "You are an FTP server. Return the FTP response for this command.",
            "postgres" => "You are a PostgreSQL database. Return the query result for this SQL.",
            "modbus" => "You are a Modbus industrial controller. Return the register values.",
            other => {
                eprintln!("Unknown service: {}, using generic prompt", other);
                "You are a network service. Respond realistically to this input."
            }
        };

        for interaction in &session.interactions {
            examples.push(TrainingExample {
                service: session.service.clone(),
                instruction: format!(
                    "{}\n\nInput: {}",
                    service_context, interaction.input
                ),
                response: interaction.output.clone(),
            });
        }
    }

    // Split 80/20 into training and evaluation sets
    let split_idx = (examples.len() as f64 * 0.8) as usize;
    let train = examples[..split_idx].to_vec();
    let eval = examples[split_idx..].to_vec();

    println!(
        "Training examples: {}, Eval examples: {}",
        train.len(),
        eval.len()
    );

    Ok(PreprocessedData { train, eval })
}

What does this code do?

  • Each protocol gets its own system prompt context. SSH examples tell the model to act as a Linux terminal, HTTP examples tell it to act as Apache, and Modbus examples tell it to act as an industrial controller.
  • The 80/20 split gives you training data to create the model and a held-out evaluation set to measure quality. The evaluation step in a later step uses this split.
  • Unknown service types get a generic prompt and a warning, so the pipeline keeps running even if new protocols are added to the honeypot before the pipeline code is updated.
  • Save src/preprocess.rs.
  • Update src/main.rs to wire up the new modules and test them:
//! Honeypot CT Pipeline — ingestion + preprocessing test.

mod generate_data;
mod ingest;
mod preprocess;

fn main() {
    // Step 1: Generate sample data if needed
    if !std::path::Path::new("./sample_logs").exists() {
        generate_data::generate_sample_logs("./sample_logs", 50);
    }

    // Step 2: Ingest all session logs
    let sessions = ingest::ingest_logs("./sample_logs")
        .expect("Failed to ingest logs");

    // Step 3: Preprocess into training data
    let data = preprocess::preprocess(&sessions)
        .expect("Failed to preprocess data");

    println!("Pipeline front-half complete.");
    println!("Train: {} examples, Eval: {} examples", data.train.len(), data.eval.len());
}
  • Save src/main.rs.
  • Run the pipeline to test ingestion and preprocessing:
cargo run

You should see ingestion counts, preprocessing splits, and a summary like Train: 160 examples, Eval: 40 examples.

Seeing a deserialization error?

  • If you see a compile error about crate::ingest, make sure mod ingest; is declared in src/main.rs.
  • If deserialization fails, check that your RawSession struct fields match the JSON keys in your generated log files exactly.

Help me fix a deserialization error in my pipeline.

Your pipeline can now ingest multi-protocol logs and transform them into clean, protocol-aware training data. Next up, you will write the training step that takes this data and creates a custom Ollama model.

Build the QLoRA Fine-Tuning Step

Build the Training Module

Your pipeline can now ingest raw honeypot logs from all five protocols and transform them into clean, protocol-aware training data.

Now for the core of the pipeline: teaching the model to generate more convincing responses. You will write a training module that generates an Ollama Modelfile from preprocessed data, calls the Ollama API to create a custom model, and saves training metadata for evaluation.

In this step, get ready to:
  • Write the training module that generates an Ollama Modelfile with embedded training examples.
  • Call the Ollama API to create a custom model from the Modelfile.
  • Save training metadata for the evaluation step.
Write the training module

Ollama lets you create custom models by writing a Modelfile that defines a base model, a system prompt, and example conversations. Your training module takes the preprocessed instruction-response pairs and bakes them into a Modelfile that teaches the model how to respond to each protocol.

How does Ollama model creation work?

Ollama's ollama create command reads a Modelfile and creates a new model variant. The Modelfile uses FROM to set the base model, SYSTEM to set the default persona, and MESSAGE blocks to embed few-shot training examples. The more examples you include, the more the model adapts to honeypot-style responses.

  • Create a new file called src/train.rs in your project folder and add the following code:
//! Training module: create a custom Ollama model from preprocessed data.

use crate::preprocess::TrainingExample;
use chrono::Utc;
use serde::Serialize;
use std::fs;
use std::process::Command;

/// Metadata about a completed training run.
#[derive(Debug, Serialize)]
pub struct TrainingResult {
    pub model_name: String,
    pub base_model: String,
    pub training_examples: usize,
    pub created_at: String,
    pub modelfile_path: String,
    pub success: bool,
}

/// Generate a Modelfile and create a custom Ollama model.
pub fn train_model(
    train_data: &[TrainingExample],
    base_model: &str,
    output_model: &str,
) -> Result<TrainingResult, Box<dyn std::error::Error>> {
    let output_dir = "./ct_output";
    fs::create_dir_all(output_dir)?;

    // Build the Modelfile content
    let mut modelfile = String::new();
    modelfile.push_str(&format!("FROM {}\n\n", base_model));
    modelfile.push_str("SYSTEM \"\"\"You are a multi-protocol honeypot. ");
    modelfile.push_str("When given a command or request for any protocol ");
    modelfile.push_str("(SSH, HTTP, FTP, PostgreSQL, Modbus), return only ");
    modelfile.push_str("the realistic service output. Do not explain. ");
    modelfile.push_str("Do not add commentary.\"\"\"\n\n");

    // Keep temperature low for deterministic, realistic output
    modelfile.push_str("PARAMETER temperature 0.3\n");
    modelfile.push_str("PARAMETER top_p 0.9\n\n");

    // Embed training examples as MESSAGE blocks
    let max_examples = train_data.len().min(50);
    for example in train_data.iter().take(max_examples) {
        // Escape quotes in the content
        let input = example.instruction.replace('"', "\\\"" );
        let output = example.response.replace('"', "\\\"");
        modelfile.push_str(&format!("MESSAGE user \"{}\"\n", input));
        modelfile.push_str(&format!("MESSAGE assistant \"{}\"\n\n", output));
    }

    // Write the Modelfile to disk
    let modelfile_path = format!("{}/Modelfile", output_dir);
    fs::write(&modelfile_path, &modelfile)?;
    println!("Modelfile written to {}", modelfile_path);
    println!("Embedded {} training examples", max_examples);

    // Call ollama create to build the custom model
    println!("Creating Ollama model '{}' from base '{}'...", output_model, base_model);
    let output = Command::new("ollama")
        .args(["create", output_model, "-f", &modelfile_path])
        .output()?;

    let success = output.status.success();
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    if success {
        println!("Model '{}' created successfully.", output_model);
    } else {
        eprintln!("Model creation failed: {}", stderr);
    }

    // Save training metadata
    let result = TrainingResult {
        model_name: output_model.to_string(),
        base_model: base_model.to_string(),
        training_examples: max_examples,
        created_at: Utc::now().to_rfc3339(),
        modelfile_path: modelfile_path.clone(),
        success,
    };

    let metadata_path = format!("{}/training_result.json", output_dir);
    let json = serde_json::to_string_pretty(&result)?;
    fs::write(&metadata_path, json)?;
    println!("Training metadata saved to {}", metadata_path);

    Ok(result)
}

What does this code do?

  • The Modelfile starts with FROM mistral which tells Ollama to use Mistral 7B as the base model. The SYSTEM block defines the multi-protocol honeypot persona.
  • MESSAGE user and MESSAGE assistant blocks embed training examples directly into the model definition. Ollama uses these as few-shot examples that shape the model's behavior.
  • The module caps at 50 examples to keep the Modelfile size manageable. In production, you would increase this or use Ollama's fine-tuning API when available.
  • Command::new("ollama") shells out to the Ollama CLI. This is the standard way to create models programmatically until the REST API adds a create endpoint.
  • Save src/train.rs.

You won't see any output yet. The training step runs as part of the full pipeline, which you will wire up in the next step.

Seeing a model creation error?

  • If the Modelfile write fails, make sure the ct_output/ directory is writable. The module creates it automatically, but disk permissions could block it.
  • If ollama create fails, make sure Ollama is running. Start it with ollama serve in a separate terminal.

Why is my Ollama model creation failing?

Your training module is ready. Next up, you will write the evaluation and deployment gate, wire all modules together, and run the full pipeline end-to-end.

Build Evaluation, Gate, and Run the Full Pipeline

Your training module creates a custom Ollama model from preprocessed honeypot data. Training a model is only half the story.

A production pipeline needs to decide whether the retrained model is good enough to deploy. In this step, you will write an evaluation module that tests the model against held-out data, a deployment gate that enforces quality thresholds, and wire everything into a single pipeline binary you run end-to-end.

In this step, get ready to:
  • Write evaluation and deployment gate modules that test model quality and block bad models.
  • Wire all pipeline stages into a single main function and run it end-to-end.
  • Test the deployed model with sample attacker commands.
Write the evaluation module

The evaluation module sends held-out test inputs to the trained model via the Ollama API, compares responses against expected outputs, and scores the model on response quality.

  • Create a new file called src/evaluate.rs in your project folder and add the following code:
//! Evaluation module: test model quality against held-out data.

use crate::preprocess::TrainingExample;
use crate::train::TrainingResult;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct GenerateRequest {
    model: String,
    prompt: String,
    stream: bool,
}

#[derive(Deserialize)]
struct GenerateResponse {
    response: String,
}

/// Results from evaluating the trained model.
#[derive(Debug, Serialize)]
pub struct EvalResults {
    pub model_name: String,
    pub eval_samples: usize,
    pub responses_received: usize,
    pub non_empty_responses: usize,
    pub accuracy_score: f64,
    pub model_exists: bool,
    pub training_success: bool,
}

/// Evaluate the trained model against held-out eval data.
pub fn evaluate_model(
    training_result: &TrainingResult,
    eval_data: &[TrainingExample],
) -> Result<EvalResults, Box<dyn std::error::Error>> {
    let client = Client::new();
    let mut responses_received = 0;
    let mut non_empty = 0;

    // Test up to 10 eval examples
    let test_count = eval_data.len().min(10);

    for example in eval_data.iter().take(test_count) {
        let request = GenerateRequest {
            model: training_result.model_name.clone(),
            prompt: example.instruction.clone(),
            stream: false,
        };

        match client
            .post("http://localhost:11434/api/generate")
            .json(&request)
            .send()
        {
            Ok(resp) => {
                if let Ok(gen) = resp.json::<GenerateResponse>() {
                    responses_received += 1;
                    if !gen.response.trim().is_empty() {
                        non_empty += 1;
                    }
                }
            }
            Err(e) => {
                eprintln!("Eval request failed: {}", e);
            }
        }
    }

    let accuracy = if test_count > 0 {
        non_empty as f64 / test_count as f64
    } else {
        0.0
    };

    let results = EvalResults {
        model_name: training_result.model_name.clone(),
        eval_samples: test_count,
        responses_received,
        non_empty_responses: non_empty,
        accuracy_score: accuracy,
        model_exists: training_result.success,
        training_success: training_result.success,
    };

    println!("Evaluation results:");
    println!("  Samples tested: {}", test_count);
    println!("  Non-empty responses: {}/{}", non_empty, test_count);
    println!("  Accuracy score: {:.2}", accuracy);

    Ok(results)
}

/// Deployment gate: approve only if quality checks pass.
pub fn gate_deployment(
    results: &EvalResults,
    min_accuracy: f64,
) -> bool {
    let approved = results.accuracy_score >= min_accuracy
        && results.model_exists
        && results.training_success;

    let status = if approved { "APPROVED" } else { "REJECTED" };
    println!("Deployment {}", status);
    println!("  Accuracy: {:.2} (threshold: {:.2})", results.accuracy_score, min_accuracy);
    println!("  Model exists: {}", results.model_exists);
    println!("  Training success: {}", results.training_success);

    approved
}

What do the evaluation and gate check?

  • The evaluation sends each test example's instruction to the trained model via the Ollama /api/generate endpoint and checks whether the response is non-empty.
  • The accuracy score is the fraction of eval examples that produced a non-empty response. A model that returns empty strings or errors fails the quality check.
  • gate_deployment() enforces three conditions: accuracy above the threshold, model exists, and training succeeded. All three must pass for deployment approval.
Wire all modules and run the full pipeline

You now have five modules: data generation, ingestion, preprocessing, training, and evaluation. The final main.rs wires them all into a single pipeline that runs sequentially.

  • Open src/main.rs and replace its contents with the full pipeline:
//! Honeypot Continuous Training Pipeline
//!
//! Ingests multi-protocol honeypot logs, preprocesses into training data,
//! creates a custom Ollama model, evaluates quality, and gates deployment.

mod evaluate;
mod generate_data;
mod ingest;
mod preprocess;
mod train;

fn main() {
    println!("=== Honeypot CT Pipeline ===");
    println!();

    // Step 1: Generate sample data if needed
    if !std::path::Path::new("./sample_logs").exists() {
        println!("--- Step 1: Generating sample data ---");
        generate_data::generate_sample_logs("./sample_logs", 50);
    } else {
        println!("--- Step 1: Sample data exists, skipping ---");
    }
    println!();

    // Step 2: Ingest all session logs
    println!("--- Step 2: Ingesting logs ---");
    let sessions = ingest::ingest_logs("./sample_logs")
        .expect("Failed to ingest logs");
    println!();

    // Step 3: Preprocess into training data
    println!("--- Step 3: Preprocessing ---");
    let data = preprocess::preprocess(&sessions)
        .expect("Failed to preprocess data");
    println!();

    // Step 4: Train a custom Ollama model
    println!("--- Step 4: Training ---");
    let training_result = train::train_model(
        &data.train,
        "mistral",
        "honeypot-ct",
    ).expect("Training failed");
    println!();

    // Step 5: Evaluate the model
    println!("--- Step 5: Evaluating ---");
    let eval_results = evaluate::evaluate_model(
        &training_result,
        &data.eval,
    ).expect("Evaluation failed");
    println!();

    // Step 6: Deployment gate
    println!("--- Step 6: Deployment Gate ---");
    let approved = evaluate::gate_deployment(&eval_results, 0.7);
    println!();

    if approved {
        println!("Pipeline complete. Model 'honeypot-ct' is ready for deployment.");
    } else {
        println!("Pipeline complete. Model did not pass quality checks.");
    }
}
  • Save src/main.rs.
  • Make sure Ollama is running in a separate terminal:
ollama serve
  • Run the full pipeline:
cargo run

The pipeline executes all six stages in sequence. You should see output from each stage: data generation counts, ingestion totals, preprocessing splits, model creation status, evaluation scores, and the final APPROVED or REJECTED decision.

Pipeline fails at the training step?

  • Make sure Ollama is running. The training and evaluation steps both call the Ollama API.
  • If model creation fails, check that the Mistral model is still pulled by running ollama list.

Help me debug my pipeline execution.

  • After the pipeline completes successfully, test the deployed model by running:
ollama run honeypot-ct "whoami"

You should see a terminal-style response. Try other commands too:

ollama run honeypot-ct "SELECT version()"

Your CT pipeline runs end-to-end and produces a deployed, quality-gated model. Next up, you will secure the pipeline by adding file protections and secrets management.

Secure Your Pipeline

Your pipeline runs end-to-end, and the ZenML dashboard tracks every artifact from ingestion through deployment gating. The pipeline itself works. Now you need to make sure the data it handles stays safe.

Raw honeypot logs contain real attacker IP addresses. Model adapters can be several gigabytes. In this step, you'll add version control protections, restrictive file permissions, and secrets management to keep sensitive data locked down.

In this step, get ready to:
  • Add a .gitignore to prevent sensitive data and large files from reaching version control.
  • Set restrictive file permissions on data and model directories.
  • Configure ZenML secrets for sensitive pipeline configuration.
Add .gitignore and data protection

Your project directory now contains attacker session logs, multi-gigabyte model weights, and ZenML tracking metadata. A single careless git add . could push all of this to a remote repository.

  • Create a file called .gitignore in the root of your honeypot-ct-pipeline folder with the following content:
# Honeypot data (contains attacker IPs and session data)
sample_logs/
raw_logs/

# Model artifacts and training outputs
ct_output/
*.bin
*.safetensors
*.gguf

# ZenML local artifacts
.zen/

# Python
__pycache__/
*.pyc
.venv/
venv/

# Environment secrets
.env

What does this .gitignore cover?

  • sample_logs/ and raw_logs/ keep raw attacker session data out of version control.
  • ct_output/, *.bin, *.safetensors, and *.gguf exclude model weights that can be several gigabytes each.
  • .zen/ excludes ZenML's local tracking data.
  • .env prevents environment variables and secrets from being committed.
  • Save .gitignore.
  • Confirm the file was created by running:
ls -a

You should see .gitignore listed in the output.

Don't see .gitignore in the output?

  • Make sure you saved the file in your honeypot-ct-pipeline folder, not inside a subdirectory.
  • Files starting with a dot are hidden by default. Make sure you are running ls -a with the -a flag, not plain ls.
  • Why isn't my .gitignore file showing up?

Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

I'd like to double check the full code

# Honeypot data (contains attacker IPs and session data)
sample_logs/
raw_logs/

# Model artifacts and training outputs
ct_output/
*.bin
*.safetensors
*.gguf

# ZenML local artifacts
.zen/

# Python
__pycache__/
*.pyc
.venv/
venv/

# Environment secrets
.env

File permissions add another layer of protection. Even with .gitignore in place, other users or processes on your system could still read the raw data directories directly.

  • Set restrictive permissions on your data and output directories by running:
chmod 700 ./sample_logs/ ./ct_output/
  • Verify the permissions were applied by running:
ls -ld ./sample_logs/ ./ct_output/

You should see drwx------ at the start of each line. This confirms only your user account has read, write, and execute access.

Seeing 'No such file or directory'?

  • Make sure you've run the pipeline at least once before setting permissions. The sample_logs/ directory is created by generate_sample_data.py, and ct_output/ is created during the training step.
  • Check that your terminal is in the honeypot-ct-pipeline folder by running pwd.
  • Help me fix my chmod command for the honeypot pipeline directories.
Configure ZenML secrets for sensitive data

When your pipeline evolves to connect to real honeypot infrastructure, you'll need API keys and connection strings. Hardcoding these values in your pipeline code is a security risk.

ZenML's secrets manager stores sensitive configuration values securely outside your codebase and artifact store.

  • Store sensitive configuration as a ZenML secret by running:
zenml secret create honeypot_config --honeypot_host=your-honeypot-host-here --api_key=your-api-key-here

What does this command do?

  • zenml secret create stores encrypted key-value pairs in ZenML's secrets backend.
  • The values never appear in your pipeline code, logs, or versioned artifacts.
  • Replace your-honeypot-host-here and your-api-key-here with real values when connecting to production infrastructure.
  • Verify your secret was created by running:
zenml secret list

You should see honeypot_config listed in the output.

How do I access secrets in my pipeline?

In any pipeline step, import the ZenML client with from zenml.client import Client. Then call Client().get_secret("honeypot_config") to retrieve the secret object.

Access individual values with secret.secret_values["honeypot_host"]. This keeps credentials out of your codebase entirely. ZenML injects them at runtime.

🙋‍♀️ Secret creation failed?

  • Make sure your ZenML server is running. Run zenml login --local if the server has stopped since Step 2.
  • Check that you don't already have a secret with the same name. If you do, delete it first with zenml secret delete honeypot_config and then create it again.
  • Help me troubleshoot ZenML secret creation.

💡 Your pipeline's full security picture

You now have three layers of data protection working together.

  • Data sanitization in the preprocess_data step replaces raw attacker IPs with SHA-256 hashes before they enter the artifact store.
  • Version control protection through .gitignore and chmod 700 keeps raw logs, model weights, and tracking data off remote repositories and locked to your user.
  • Secrets management through ZenML stores API keys and connection strings outside your code, injecting them only at runtime.

Secret mission

Add GGUF Export and Auto-Deploy to Ollama

Your pipeline trains and gates the model, but right now it just sits on disk as a LoRA adapter. What if the pipeline could automatically export the approved model to a format your honeypot can serve, then load it into Ollama with a single command? Take the pipeline all the way to production by adding GGUF export and auto-deployment.

Before you go

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on your GPU, so there are no ongoing cloud costs.

Resources you used:

  • Python virtual environment (.venv/).
  • Sample honeypot logs (sample_logs/).
  • Model adapters and training outputs (ct_output/).
  • ZenML local project and artifacts (.zen/).
  • ZenML local dashboard server.
  • Cached Mistral 7B model in HuggingFace cache (~4GB).
  • ZenML secrets (if created).
  • Pipeline source files (generate_sample_data.py, ct_pipeline.py, .gitignore).

Keep everything running

No action needed. Choose this if you're still actively building or plan to run the pipeline again soon.

  • Your ZenML dashboard keeps running until you close the terminal or stop the server.
  • Your virtual environment, sample logs, and model adapters remain in your project directory.
  • The cached Mistral 7B model stays in your HuggingFace cache so future runs skip the download.

Pause - I'll come back to this later

Shut down running processes to free up memory and GPU resources, but keep all files so you can resume later.

  • Stop the ZenML dashboard server by pressing Ctrl+C in the terminal where it is running.
  • Deactivate your virtual environment by running:
deactivate
  • Your project directory, sample logs, model adapters, and ZenML artifacts all stay on disk.
  • To resume, reactivate the virtual environment and run zenml login --local to restart the dashboard.

Delete - I don't want to use this again

Remove all project resources and start fresh. This frees disk space used by the model, adapters, and logs.

  • Stop the ZenML dashboard server by pressing Ctrl+C in the terminal where it is running.
  • Delete any ZenML secrets you created:
zenml secret list
zenml secret delete <secret-name>
  • Clean up the ZenML project:
zenml clean
  • Deactivate the virtual environment and delete the entire project directory:
deactivate
cd ..
rm -rf honeypot-ct-pipeline
  • To free ~4GB of disk space, remove the cached Mistral model from the HuggingFace cache:
rm -rf ~/.cache/huggingface/hub/models--unsloth--mistral-7b-v0.3-bnb-4bit

Mission accomplished

Nice work! You just built an autonomous Continuous Training pipeline that takes raw honeypot logs and turns them into a freshly fine-tuned LLM, with every step tracked, versioned, and gated for quality.

You've learned how to:

  • Build a complete ZenML CT pipeline with 5 orchestrated steps (ingest, preprocess, train, evaluate, gate) that eliminates manual retraining cycles.
  • Fine-tune Mistral 7B with QLoRA on an RTX 3050 using Unsloth's memory-efficient training, staying within 8GB of VRAM.
  • Secure the pipeline with IP sanitization, secrets management, file permissions, and full artifact lineage tracking in the ZenML dashboard.
  • Export the approved model to GGUF format and auto-deploy it to Ollama so your honeypot can immediately serve the retrained model.

Ready to quiz yourself?