init: Prove the concept of reproducible builds

This commit is contained in:
Matthew Binning 2025-12-12 14:34:46 -08:00
commit b957af3edb
No known key found for this signature in database
9 changed files with 2075 additions and 0 deletions

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Rust
target/
**/*.rs.bk
*.pdb
# Nix
result
result-*
.direnv/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Build outputs
build-output/

105
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,105 @@
stages:
- build
- test
- package
variables:
# Use Docker-outside-of-Docker (DooD) by mounting the host's Docker socket
DOCKER_HOST: unix:///var/run/docker.sock
# Disable TLS as we're using the local socket
DOCKER_TLS_CERTDIR: ""
# Build the Rust application using Nix
build:
stage: build
image: nixos/nix:latest
before_script:
# Enable flakes and nix-command
- mkdir -p ~/.config/nix
- echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
script:
# Generate Cargo.lock if it doesn't exist
- nix develop --command cargo generate-lockfile || true
# Build the Rust application
- nix build .#app
# Copy the result for artifacts
- mkdir -p build-output
- cp -rL result/* build-output/ || cp result build-output/hello-world
artifacts:
paths:
- build-output/
- Cargo.lock
expire_in: 1 hour
tags:
- test-ci-cd
# Test the application
test:
stage: test
image: nixos/nix:latest
before_script:
- mkdir -p ~/.config/nix
- echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
script:
# Run the application in a Nix shell with all dependencies
- nix develop --command cargo test
# You could also run the binary here if needed
# - nix run .#app
dependencies:
- build
tags:
- test-ci-cd
# Build Docker image using Nix and load it into Docker (DooD pattern)
build-docker-image:
stage: package
image: nixos/nix:latest
services:
# No docker:dind service - we'll use the host's Docker daemon
before_script:
# Install Docker CLI in the Nix container
- nix-env -iA nixpkgs.docker
# Enable flakes
- mkdir -p ~/.config/nix
- echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
script:
# Build the Docker image using Nix
- nix build .#docker
# Load the image into Docker daemon (running on host via socket)
- docker load < result
# Tag the image
- docker tag hello-world:latest hello-world:${CI_COMMIT_SHORT_SHA}
# Test run the container
- docker run --rm hello-world:latest
# Optional: Push to registry if configured
# - docker tag hello-world:latest ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
# - docker push ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}
dependencies:
- build
tags:
- test-ci-cd
# This job requires a GitLab runner with Docker socket access
# The runner should have /var/run/docker.sock mounted
# Alternative: Build using Docker directly (DooD)
build-docker-traditional:
stage: package
image: docker:latest
services: [] # No dind service
variables:
DOCKER_HOST: unix:///var/run/docker.sock
before_script:
# Verify Docker access
- docker info
script:
# Build the Docker image
- docker build -t hello-world:traditional-${CI_COMMIT_SHORT_SHA} .
# Test run
- docker run --rm hello-world:traditional-${CI_COMMIT_SHORT_SHA}
dependencies:
- build
tags:
- test-ci-cd
only:
- branches
when: manual

1531
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

9
Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "hello-world"
version = "0.1.0"
edition = "2021"
[dependencies]
neofetch = "0.2.0"
figlet-rs = "0.1.5"
tokio = { version = "1.42", features = ["full"] }

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM nixos/nix:latest
WORKDIR /app
COPY . .
RUN mkdir -p ~/.config/nix && \
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf && \
nix build .#app
# Extract the built binary
RUN cp -rL result/* /tmp/app/ || cp result /tmp/app/hello-world
# Use a minimal runtime image - no system dependencies needed!
FROM debian:bookworm-slim
# All functionality is in Rust crates, no need for system binaries
COPY --from=0 /tmp/app/ /usr/local/bin/
CMD ["/usr/local/bin/hello-world"]

81
FIGLET_FONTS.md Normal file
View file

@ -0,0 +1,81 @@
# FIGlet Font Examples
The `figlet-rs` crate supports multiple fonts. Here are some examples of how to use different fonts:
## Using Different Fonts
```rust
use figlet_rs::FIGfont;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Standard font (built-in)
let standard = FIGfont::standard()?;
if let Some(fig) = standard.convert("Standard") {
println!("{}", fig);
}
// You can also load custom fonts from files
// Download fonts from: http://www.figlet.org/fontdb.cgi
// Example with a custom font file:
// let font = FIGfont::from_file("path/to/font.flf")?;
// if let Some(fig) = font.convert("Custom") {
// println!("{}", fig);
// }
Ok(())
}
```
## Popular FIGlet Fonts
Some popular fonts you might want to try (download from figlet.org):
- **standard** - The default, classic font (built-in)
- **banner** - Large block letters (similar to the Unix `banner` command)
- **big** - Large letters
- **block** - Blocky style
- **bubble** - Rounded bubble letters
- **digital** - Digital/LED style
- **lean** - Slanted letters
- **mini** - Compact font
- **script** - Cursive style
- **shadow** - Letters with shadows
- **slant** - Slanted text
- **small** - Small letters
## Example Output (Standard Font)
```
_ _ _ _
| | | | ___| | | ___
| |_| |/ _ \ | |/ _ \
| _ | __/ | | (_) |
|_| |_|\___|_|_|\___/
```
## Adding Custom Fonts to Your Project
1. Download fonts from http://www.figlet.org/fontdb.cgi
2. Place `.flf` files in a `fonts/` directory in your project
3. Load them with `FIGfont::from_file("fonts/yourfont.flf")`
4. If using Nix, include the font files in your Docker image
## Alternative: Multiple Text Styles
You can also create multiple banner styles in your application:
```rust
fn print_banner(text: &str) {
let font = FIGfont::standard().unwrap();
if let Some(figure) = font.convert(text) {
println!("{}", figure);
}
}
fn main() {
print_banner("HELLO");
print_banner("WORLD");
}
```

172
README.md Normal file
View file

@ -0,0 +1,172 @@
# Hello World - Docker-outside-of-Docker with Rust, Nix, and GitLab CI
This project demonstrates the Docker-outside-of-Docker (DooD) pattern using:
- **Rust** for the application
- **Nix** for reproducible dependency management
- **GitLab CI** for automated builds
## What is Docker-outside-of-Docker (DooD)?
Docker-outside-of-Docker is a pattern where CI/CD pipelines build Docker images by mounting the host's Docker socket (`/var/run/docker.sock`) into the CI container, rather than running Docker-in-Docker (DinD). This approach:
- ✅ Avoids nested virtualization overhead
- ✅ Shares the Docker layer cache with the host
- ✅ Simplifies networking
- ⚠️ Requires proper security considerations (the container has full Docker daemon access)
## Project Structure
```
.
├── src/
│ └── main.rs # Rust application that calls neofetch and banner
├── Cargo.toml # Rust package manifest
├── flake.nix # Nix flake for pinned dependencies
├── .gitlab-ci.yml # GitLab CI pipeline
└── README.md # This file
```
## The Application
The Rust application (`src/main.rs`) is a simple hello-world program that:
1. Uses the `figlet-rs` crate to display ASCII art text (banner-style)
2. Uses the `neofetch` crate to gather and display system information
All functionality is implemented using native Rust crates, requiring no system binaries.
## Nix Flake
The `flake.nix` file:
- Pins exact versions of all dependencies (Rust toolchain, crates dependencies via Cargo.lock)
- Defines how to build the Rust application
- Creates a minimal Docker image with only the application binary and essential runtime dependencies
- Provides a development shell for local development
Since we're using Rust crates (`neofetch` and `figlet-rs`) instead of system binaries, the Docker image is much smaller and more portable.
### Building locally with Nix
```bash
# Enter the development environment
nix develop
# Build the application
nix build .#app
# Run the application
nix run .#app
# Build the Docker image
nix build .#docker
# Load the Docker image
docker load < result
docker run hello-world:latest
```
## GitLab CI Pipeline
The `.gitlab-ci.yml` implements DooD with three stages:
### 1. Build Stage
- Uses a Nix container to build the Rust application
- Generates `Cargo.lock` if needed
- Produces build artifacts
### 2. Test Stage
- Runs tests in a Nix environment
- Verifies the application works
### 3. Package Stage (Two Approaches)
#### Approach A: `build-docker-image` (Recommended)
- Builds the Docker image using Nix's `dockerTools.buildLayeredImage`
- Loads the image into the host's Docker daemon via DooD
- Tests the container
- Ready for pushing to a registry
#### Approach B: `build-docker-traditional` (Alternative)
- Uses a traditional Dockerfile
- Builds in two stages: Nix build + runtime image
- Manual trigger only
## GitLab Runner Configuration
For DooD to work, your GitLab Runner must be configured with Docker socket access:
### Using Docker Executor
In `/etc/gitlab-runner/config.toml`:
```toml
[[runners]]
name = "docker-runner"
executor = "docker"
[runners.docker]
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
privileged = false # DooD doesn't require privileged mode
```
### Security Considerations
⚠️ **Important**: Mounting the Docker socket gives the container full control over the Docker daemon. Only use this on trusted runners. Consider:
1. Using dedicated runners for DooD jobs
2. Implementing runner token rotation
3. Restricting which projects can use DooD runners
4. Using rootless Docker on the host
5. Implementing image scanning and security policies
## Advantages of This Setup
1. **Reproducibility**: Nix ensures exact dependency versions
2. **Efficiency**: DooD avoids DinD overhead and shares layer cache
3. **Minimal Images**: Rust crates eliminate system binary dependencies; Nix's `buildLayeredImage` creates optimized layers
4. **Type Safety**: Rust provides compile-time guarantees
5. **Declarative**: Everything is defined in code
6. **Portability**: Pure Rust implementation works across platforms without requiring system tools
## Environment Variables
The GitLab CI uses these key variables for DooD:
```yaml
DOCKER_HOST: unix:///var/run/docker.sock # Use host's Docker daemon
DOCKER_TLS_CERTDIR: "" # Disable TLS for local socket
```
## Troubleshooting
### "Cannot connect to Docker daemon"
- Ensure the runner has `/var/run/docker.sock` mounted
- Check socket permissions on the host
- Verify the Docker daemon is running on the host
### "Permission denied" on Docker socket
- The container user needs access to the socket
- Add the container user to the `docker` group on the host, or
- Change socket permissions (less secure)
### Nix build fails
- Ensure experimental features are enabled in `nix.conf`
- Check that `Cargo.lock` is generated before building
- Verify network access for downloading dependencies
## Alternative: Docker-in-Docker
If you prefer Docker-in-Docker instead of DooD, modify the CI config:
```yaml
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
```
Note: DinD requires `privileged: true` in the runner config and has performance implications.
## License
MIT

81
flake.nix Normal file
View file

@ -0,0 +1,81 @@
{
description = "Hello World Rust application with neofetch and banner";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
};
# Build the Rust application
rustApp = pkgs.rustPlatform.buildRustPackage {
pname = "hello-world";
version = "0.1.0";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
nativeBuildInputs = [ rustToolchain ];
buildInputs = [
pkgs.xorg.libxcb
];
};
# Create a Docker image with the app and dependencies
dockerImage = pkgs.dockerTools.buildLayeredImage {
name = "hello-world";
tag = "latest";
contents = [
rustApp
pkgs.bashInteractive
pkgs.coreutils
];
config = {
Cmd = [ "${rustApp}/bin/hello-world" ];
Env = [
"PATH=/bin"
];
};
};
in
{
packages = {
default = rustApp;
app = rustApp;
docker = dockerImage;
};
devShells.default = pkgs.mkShell {
buildInputs = [
rustToolchain
pkgs.rust-analyzer
pkgs.docker
pkgs.xorg.libxcb
];
shellHook = ''
echo "Rust development environment loaded!"
echo "Rust version: $(rustc --version)"
'';
};
}
);
}

58
src/main.rs Normal file
View file

@ -0,0 +1,58 @@
use figlet_rs::FIGfont;
use neofetch::Neofetch;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create ASCII art banner using figlet-rs
println!("\n");
let font = FIGfont::standard()?;
if let Some(figure) = font.convert("HELLO") {
println!("{}", figure);
}
println!("\n=== System Information ===\n");
// Get system information using neofetch crate
let neofetch = Neofetch::new().await;
// Display system information
if let Ok(os) = &neofetch.os {
println!("OS: {}", os);
}
if let Ok(host) = &neofetch.host {
println!("Host: {}", host);
}
if let Ok(kernel) = &neofetch.kernel {
println!("Kernel: {}", kernel);
}
if let Ok(uptime) = &neofetch.uptime {
println!("Uptime: {}", uptime);
}
if let Ok(shell) = &neofetch.shell {
println!("Shell: {}", shell);
}
if let Ok(terminal) = &neofetch.terminal {
println!("Terminal: {}", terminal);
}
if let Ok(cpu) = &neofetch.cpu {
println!("CPU: {:?}", cpu);
}
if let Some(gpu) = &neofetch.gpu {
println!("GPU: {:?}", gpu);
}
if let Ok(memory) = &neofetch.memory {
println!("Memory: {}", memory);
}
println!("\n=== Done! ===\n");
Ok(())
}