Initial
Some checks failed
Build and Publish Docker Image / test (push) Failing after 5s
Build and Publish Docker Image / build-and-push (push) Has been skipped
Build and Publish Docker Image / security-scan (push) Has been skipped

This commit is contained in:
Andras Schmelczer 2026-03-23 07:44:26 +00:00
commit 3f60b72c3b
48 changed files with 6599 additions and 0 deletions

41
.dockerignore Normal file
View file

@ -0,0 +1,41 @@
# Rust
target/
# Git
.git/
.gitignore
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Test files
test/
tests/
# Documentation
README.md
docs/
# CI/CD
.github/
.gitlab-ci.yml
# Runtime files
backups/
compose-files/
config.yaml
# Docker
docker-compose.yml
Dockerfile
.dockerignore

126
.github/workflows/docker-publish.yml vendored Normal file
View file

@ -0,0 +1,126 @@
name: Build and Publish Docker Image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: 1.88
override: true
components: rustfmt, clippy
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Run tests
run: GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}"cargo test --verbose
- name: Run clippy
run: cargo clippy -- -D warnings
- name: Check formatting
run: cargo fmt -- --check
build-and-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
version: ${{ steps.meta.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test Docker image
if: github.event_name != 'pull_request'
run: |
docker run --rm \
-e RUST_LOG=info \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} \
docker-compose-updater check
security-scan:
needs: build-and-push
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
permissions:
contents: read
packages: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.version }}
format: "sarif"
output: "trivy-results.sarif"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: "trivy-results.sarif"

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

83
CLAUDE.md Normal file
View file

@ -0,0 +1,83 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Build and Test
```bash
# Build the project
cargo build --release
# Run all tests
cargo test
# Run integration tests specifically
cargo test --test integration
# Run with coverage
cargo test --coverage
```
### Running the Application
```bash
# Run a one-time update
./target/release/docker-compose-updater --config ./config.yaml update
# Start the scheduler service
./target/release/docker-compose-updater --config ./config.yaml start
# Run from source in development
cargo run -- --config ./config.yaml update
cargo run -- --config ./config.yaml start
```
### Development Testing
```bash
# Test against example configuration
cargo run -- --config ./config.example.yaml update
# Run in dry-run mode to see what would be updated
cargo run -- --config ./demo-config.yaml update # (demo-config.yaml has dry_run: true)
```
## Architecture Overview
This is a Rust-based Docker Compose updater service that automatically updates image versions in Docker Compose files while preserving formatting and comments.
### Core Components
- **`main.rs`**: Entry point with CLI argument parsing using clap. Supports `start` (scheduler) and `update` (one-time) commands.
- **`config.rs`**: Configuration management with support for multiple registries, update strategies, and image ignore patterns. Loads from `config.yaml` or `/app/config/config.yaml`.
- **`compose.rs`**: Docker Compose file parsing and updating. Uses regex-based YAML parsing to preserve formatting and comments while updating image versions.
- **`registry.rs`**: Container registry interaction for fetching available image versions. Supports Docker Hub, GitHub Container Registry, GitLab, and custom registries.
- **`scheduler.rs`**: Cron-based scheduling system for automatic updates. Runs health checks and reports status.
- **`health.rs`**: Health monitoring server that exposes HTTP endpoints for service health checks.
### Key Design Patterns
- **Configuration-driven**: All behavior controlled through `config.yaml` including paths, schedules, registries, and update strategies.
- **Preserves formatting**: Uses line-by-line regex parsing rather than full YAML parsing to maintain comments and formatting.
- **Flexible update strategies**: Supports different version update approaches (latest patch of previous minor, latest patch, latest minor, latest).
- **Registry abstraction**: Generic registry client that works with multiple container registries.
- **Health monitoring**: Built-in health server for monitoring service status.
### Update Strategies
The system supports different update strategies defined in `config.rs`:
- `LatestPatchOfPreviousMinor` (default)
- `Latest`
### Configuration
- Configuration is loaded from `config.yaml` or `/app/config/config.yaml`
- Default configuration includes Docker Hub and GitHub Container Registry
- Environment variables like `GITHUB_TOKEN` are used for registry authentication
- Images can be ignored using patterns in the `ignore_images` configuration
### Testing
- Unit tests are embedded in each module using `#[cfg(test)]`
- Integration tests are in the `tests/` directory
- Uses `tempfile` for testing file operations
- Tests cover compose file parsing, image reference parsing, and scheduler creation

2059
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

34
Cargo.toml Normal file
View file

@ -0,0 +1,34 @@
[package]
name = "docker-compose-updater"
version = "0.1.0"
edition = "2021"
authors = ["Andras Schmelczer <andras@schmelczer.dev>"]
description = "Automatically update Docker Compose image versions"
license = "MIT"
[dependencies]
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time", "fs", "net", "io-util"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
semver = "1.0"
anyhow = "1.0"
regex = "1.10"
cron = "0.12"
clap = { version = "4.0", features = ["derive"] }
async-trait = "0.1"
tracing = "0.1"
tracing-subscriber = "0.3"
chrono = "0.4"
[dev-dependencies]
tempfile = "3.0"
[lib]
name = "docker_compose_updater"
path = "src/lib.rs"
[[bin]]
name = "docker-compose-updater"
path = "src/main.rs"

46
Dockerfile Normal file
View file

@ -0,0 +1,46 @@
FROM rust:1.85-alpine AS builder
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
# Build the application
RUN cargo build --release
# Runtime stage
FROM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
tzdata \
curl
# Create non-root user
RUN addgroup -g 1000 updater && \
adduser -u 1000 -G updater -s /bin/sh -D updater
# Create necessary directories
RUN mkdir -p /app/config && \
chown -R updater:updater /app
# Copy binary from builder stage
COPY --from=builder /app/target/release/docker-compose-updater /usr/local/bin/docker-compose-updater
# Switch to non-root user
USER updater
# Set working directory
WORKDIR /app
# Expose health check port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
CMD ["docker-compose-updater", "--config", "/app/config/config.yaml", "start"]

377
README.md Normal file
View file

@ -0,0 +1,377 @@
# Docker Compose Updater
A robust, production-ready Rust service that automatically updates Docker Compose image versions whilst preserving comments, formatting, and maintaining operational stability. The service operates on a configurable schedule and uses intelligent update strategies to ensure safe, conservative updates.
## Key Features
- 🔄 **Intelligent Updates**: Automatically updates Docker Compose files using configurable update strategies
- 📝 **Format Preservation**: Maintains all comments, whitespace, and YAML formatting perfectly
- 🎯 **Conservative Strategies**: Defaults to stable, tested versions rather than bleeding edge
- 🏥 **Health Monitoring**: Built-in health endpoints for monitoring and observability
- 🔐 **Multi-Registry Support**: Works with Docker Hub, GitHub Container Registry, GitLab, and custom registries
- 🔍 **Flexible Filtering**: Configurable patterns to ignore specific images or registries
- 🛡️ **Robust Error Handling**: Comprehensive error management with no silent failures
- 📋 **Prefix/Suffix Support**: Handles complex versioning schemes like `v1.2.3-alpine`, `release-1.0.0-slim`
- 🚀 **Container-Ready**: Designed for containerised environments with proper security
- 📊 **Comprehensive Testing**: Extensively tested with 44+ test cases covering edge cases
## Quick Start
### Using Docker Compose (Recommended)
1. **Create a configuration file** (`config.yaml`):
```yaml
# Paths to scan for Docker Compose files
compose_paths:
- "./compose-files"
- "./docker-compose.yml"
- "./services"
# Cron expression for update schedule
schedule: "0 0 2 * * *" # Daily at 2 AM UTC
# Update strategy (conservative by default)
update_strategy: "LatestPatchOfPreviousMinor"
# Runtime behaviour
dry_run: false # Set to true for testing
# Images to ignore during updates (substring matching)
ignore_images:
- "localhost"
- "127.0.0.1"
- "internal.company.com"
# Registry configurations
registries:
"docker.io":
url: "https://registry-1.docker.io"
"ghcr.io":
url: "https://ghcr.io"
auth_token: "${GITHUB_TOKEN}"
```
2. **Deploy with Docker Compose**:
```yaml
version: '3.8'
services:
docker-compose-updater:
image: ghcr.io/your-username/docker-compose-updater:latest
container_name: docker-compose-updater
restart: unless-stopped
environment:
- GITHUB_TOKEN=${GITHUB_TOKEN}
- RUST_LOG=info
volumes:
# Mount Docker socket (read-only for security)
- /var/run/docker.sock:/var/run/docker.sock:ro
# Mount compose files directory
- ./compose-files:/app/compose-files:rw
# Mount configuration
- ./config.yaml:/app/config/config.yaml:ro
ports:
- "8080:8080" # Health monitoring port
user: "1000:1000" # Run as non-root user
```
3. **Start the service**:
```bash
docker-compose up -d
```
### Command Line Usage
```bash
# Install or build the binary
cargo build --release
# Run a one-time update
./target/release/docker-compose-updater --config ./config.yaml update
# Start the scheduled service with health monitoring
./target/release/docker-compose-updater --config ./config.yaml start
```
## Update Strategies
The service supports three intelligent update strategies, defaulting to the most conservative:
### `LatestPatchOfPreviousMinor`
- **Predictable**: Always updates to the latest patch of the previous minor version
- **Consistent Behaviour**: Never updates to the current latest minor
- **Example**: `1.5.3``1.4.8`
### `Latest`
- **Aggressive**: Updates to the absolute latest available version
- **Cutting Edge**: Useful for development environments
- **Example**: `1.5.3``2.1.0`
## Advanced Version Handling
The service handles complex versioning schemes intelligently:
### Supported Version Formats
- **Standard Semantic Versions**: `1.2.3`, `2.0.1`
- **Prefixed Versions**: `v1.2.3`, `release-1.0.0`, `build123-2.1.0`
- **Suffixed Versions**: `1.2.3-alpine`, `2.0.1-slim`, `1.5.0-ubuntu20.04`
- **Combined**: `v1.2.3-alpine-slim`, `release-2.0.1-final`
### Version Matching Logic
The service ensures that prefix and suffix combinations remain consistent:
- `v1.2.3-alpine` will only be updated to other `v*.*.*-alpine` versions
- `release-1.0.0` will only be updated to other `release-*.*.*` versions
- This prevents incompatible image variants from being selected
## Comprehensive Registry Support
### Supported Registries
- **Docker Hub**: `docker.io` (default registry)
- **GitHub Container Registry**: `ghcr.io`
- **GitLab Container Registry**: `registry.gitlab.com`
- **Google Container Registry**: `gcr.io`
- **Amazon ECR**: `*.dkr.ecr.*.amazonaws.com`
- **Azure Container Registry**: `*.azurecr.io`
- **Custom/Private Registries**: Any registry following Docker Registry API v2
### Registry Configuration
```yaml
registries:
"docker.io":
url: "https://registry-1.docker.io"
# Docker Hub typically doesn't require auth for public images
"ghcr.io":
url: "https://ghcr.io"
auth_token: "${GITHUB_TOKEN}"
"registry.gitlab.com":
url: "https://registry.gitlab.com"
auth_token: "${GITLAB_TOKEN}"
"your-registry.company.com":
url: "https://your-registry.company.com"
auth_token: "${COMPANY_REGISTRY_TOKEN}"
```
## Supported Image Formats
The updater handles various image naming conventions:
```yaml
# Standard Docker Hub images
nginx:1.21.0 → nginx:1.20.6
ubuntu:20.04 → ubuntu:18.04
# Namespaced images
bitnami/nginx:1.21.0 → bitnami/nginx:1.20.6
library/postgres:13.7 → library/postgres:12.11
# Custom registries
ghcr.io/user/app:v1.0.0 → ghcr.io/user/app:v0.9.5
registry.example.com/team/service:2.1.3 → registry.example.com/team/service:2.0.9
# Complex versioning
nginx:1.21.0-alpine → nginx:1.20.6-alpine
postgres:13.7-bullseye → postgres:12.11-bullseye
redis:v7.0.0-alpine3.16 → redis:v6.2.7-alpine3.16
# Local development (typically ignored)
localhost:5000/myapp:latest → [ignored by default]
127.0.0.1:8080/service:dev → [ignored by default]
```
## Health Monitoring & Observability
The service provides comprehensive monitoring capabilities:
### Health Endpoints
- **`GET /`**: Basic health check
- Returns `{"status":"healthy"}` when operational
- Returns `{"status":"unhealthy"}` during failures
- Includes basic service information
### Logging
```bash
# Configure logging level via environment variable
RUST_LOG=debug # For detailed debugging
RUST_LOG=info # For operational information (default)
RUST_LOG=warn # For warnings and errors only
RUST_LOG=error # For errors only
```
### Service Monitoring
The service reports:
- Successful update cycles with file counts
- Failed updates with detailed error information
- Registry connectivity status
- Configuration validation results
## Security Features
- **Non-Root Execution**: Designed to run as non-root user in containers
- **Minimal Attack Surface**: Alpine Linux base image with minimal packages
- **Read-Only Docker Socket**: Only requires read access to Docker socket
- **Secret Management**: All tokens provided via environment variables
- **Input Validation**: Comprehensive validation of all configuration inputs
- **Error Sanitisation**: No sensitive information leaked in error messages
## Development
### Building from Source
```bash
# Clone the repository
git clone https://github.com/your-username/docker-compose-updater.git
cd docker-compose-updater
# Build in release mode
cargo build --release
# Run comprehensive test suite
cargo test
# Run with custom configuration
./target/release/docker-compose-updater --config ./config.example.yaml start
```
### Testing
The project includes extensive testing:
```bash
# Run all tests (44+ test cases)
cargo test
# Run specific test suites
cargo test --test integration_tests # Integration tests
cargo test --test test_error_handling # Error handling tests
cargo test --test test_config_validation # Configuration tests
cargo test --test test_compose_operations # Compose file operations
# Run with output for debugging
cargo test -- --nocapture
# Generate coverage report (requires cargo-llvm-cov)
cargo llvm-cov --html
```
### Test Coverage
- **Error Handling**: Invalid configurations, malformed files, network failures
- **Config Validation**: All configuration formats, edge cases, defaults
- **Compose Operations**: Complex file formats, image parsing, version handling
- **Registry Integration**: Multiple registry types, authentication, failures
- **Version Logic**: All update strategies, complex versioning schemes
## Configuration Reference
### Complete Configuration Example
```yaml
# File/directory paths to scan for Docker Compose files
compose_paths:
- "/app/compose-files" # Directory to scan recursively
- "./docker-compose.yml" # Specific file
- "./services/" # Another directory
# Cron expression for update schedule (UTC timezone)
schedule: "0 0 2 * * *" # Daily at 2 AM
# Update strategy selection
update_strategy: "LatestPatchOfPreviousMinor"
# Prevent actual file modifications (for testing)
dry_run: false
# Images to ignore during updates (substring matching)
ignore_images:
- "localhost" # Local development images
- "127.0.0.1" # Local registry
- "internal.company.com" # Private internal registry
- "snapshot" # Snapshot versions
# Registry configurations
registries:
"docker.io":
url: "https://registry-1.docker.io"
# auth_token not typically needed for public images
"ghcr.io":
url: "https://ghcr.io"
auth_token: "${GITHUB_TOKEN}"
"registry.gitlab.com":
url: "https://registry.gitlab.com"
auth_token: "${GITLAB_TOKEN}"
```
### Environment Variables
- **`GITHUB_TOKEN`**: Personal access token for GitHub Container Registry
- **`GITLAB_TOKEN`**: Access token for GitLab Container Registry
- **`RUST_LOG`**: Logging level (`debug`, `info`, `warn`, `error`)
- **Registry-specific tokens**: For private registries
## Operational Considerations
### Backup Strategy
- Always backup your Docker Compose files before running updates
- Use version control (Git) to track changes
- Test with `dry_run: true` before enabling actual updates
### Monitoring
- Monitor the health endpoint for service availability
- Set up alerts for failed update cycles
- Review logs regularly for registry connectivity issues
### Resource Requirements
- **Memory**: ~10-50MB during operation
- **CPU**: Minimal during dormant periods, brief spikes during updates
- **Network**: Requires outbound HTTPS access to configured registries
- **Storage**: Minimal, only for configuration and temporary files
## Troubleshooting
### Common Issues
**Service fails to start**
- Check configuration file syntax with `yamllint`
- Verify file paths exist and are accessible
- Ensure cron expression is valid
**Registry authentication failures**
- Verify environment variables are set correctly
- Check token permissions for the target registry
- Test registry connectivity manually
**Updates not applied**
- Check if `dry_run` is enabled
- Verify images are not in the ignore list
- Ensure semantic versioning is used in image tags
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Write tests for your changes
4. Ensure all tests pass (`cargo test`)
5. Commit your changes (`git commit -m 'Add amazing feature'`)
6. Push to the branch (`git push origin feature/amazing-feature`)
7. Open a Pull Request
## Licence
This project is licensed under the MIT Licence - see the [LICENSE](LICENSE) file for details.
## Changelog
### v0.1.0
- Initial release with comprehensive functionality
- Multi-registry support (Docker Hub, GHCR, GitLab, custom)
- Intelligent update strategies with conservative defaults
- Comment and formatting preservation
- Comprehensive error handling (no silent failures)
- Health monitoring and observability
- Extensive test coverage (44+ test cases)
- Prefix/suffix version handling
- Robust configuration validation
- Production-ready security features

4
compose/backup.yml Normal file
View file

@ -0,0 +1,4 @@
name: active
services:
backup:
image: ghcr.io/schmelczer/backup-container:v0.0.3

11
compose/calibre.yml Normal file
View file

@ -0,0 +1,11 @@
name: active
services:
audiobookshelf:
image: advplyr/audiobookshelf:2.26.0
calibre-web:
image: "" # required for wud
build:
networks:
local-network:

10
compose/cloud.yml Normal file
View file

@ -0,0 +1,10 @@
name: active
services:
filebrowser:
image: filebrowser/filebrowser:v2.40.1
syncthing:
image: syncthing/syncthing:1.30.0
networks:
local-network:

4
compose/dns.yml Normal file
View file

@ -0,0 +1,4 @@
name: active
services:
dns-server:
image: technitium/dns-server:13.6.0

39
compose/frontdoor.yml Normal file
View file

@ -0,0 +1,39 @@
name: active
services:
authelia:
image: authelia/authelia:4.39.5
certbot:
image: certbot/dns-digitalocean:v4.1.1
dyndns:
build:
context: /volumes/dyndns
container_name: frontdoor_dyndns
environment:
TZ: $TIME_ZONE
DOMAIN: schmelczer.dev
NAME: home;cloud;dns;immich;declared;pdf;homeassistant;store;audiobook;books;movies;stats;wud;auth;paperless;minecraft;torrent;obsidian;ghostfolio;vpn;mealie
DIGITALOCEAN_TOKEN: $DIGITALOCEAN_TOKEN
networks:
- local-network
restart: unless-stopped
init: true
tty: true
fail2ban:
image: ghcr.io/crazy-max/fail2ban:1.1.0
container_name: frontdoor_fail2ban
volumes:
- /volumes/fail2ban:/data
- /volumes/nginx/logs:/var/log:ro
- /volumes/vaultwarden/data/vaultwarden.log:/vaultwarden/vaultwarden.log:ro
environment:
TZ: $TIME_ZONE
F2B_LOG_TARGET: STDOUT
tinyauth:
image: ghcr.io/steveiliop56/tinyauth:v3.6.2
nginx:
image: nginx:1.29.0

20
compose/github-runner.yml Normal file
View file

@ -0,0 +1,20 @@
name: active
services:
github-runner:
build:
context: /volumes/github-runner
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- REPO_URL=https://github.com/schmelczer/vault-link
- ACCESS_TOKEN=$GITHUB_ACTIONS_RUNNER_TOKEN
restart: unless-stopped
deploy:
resources:
limits:
cpus: '6'
memory: '8G'
mode: replicated
replicas: 3
init: true
tty: true

View file

@ -0,0 +1,4 @@
name: active
services:
homeassistant:
image: homeassistant/home-assistant:2025.7.2

17
compose/immich.yml Normal file
View file

@ -0,0 +1,17 @@
name: active
services:
database:
image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
immich-folder-album-creator:
image: salvoxia/immich-folder-album-creator:0.19.0
immich-machine-learning:
image: ghcr.io/immich-app/immich-machine-learning:v1.135.3
nas_sync:
build:
context: /volumes/nas-sync
redis:
image: docker.io/redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8

22
compose/media.yml Normal file
View file

@ -0,0 +1,22 @@
name: active
services:
gluetun:
image: qmcgaw/gluetun:v3.40.0
jellyfin:
image: jellyfin/jellyfin:10.10.7
jellyseerr:
image: fallenbagel/jellyseerr:2.7.1
prowlarr:
image: lscr.io/linuxserver/prowlarr:1.37.0
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:5.1.2-libtorrentv1
radarr:
image: lscr.io/linuxserver/radarr:5.26.2
sonarr:
image: lscr.io/linuxserver/sonarr:4.0.15

4
compose/minecraft.yml Normal file
View file

@ -0,0 +1,4 @@
name: active
services:
minecraft_andris:
image: itzg/minecraft-server:2025.6.2-java21-graalvm

4
compose/obsidian.yml Normal file
View file

@ -0,0 +1,4 @@
name: active
services:
obsidian:
image: ghcr.io/schmelczer/vault-link:0.5.1

19
compose/paperless.yml Normal file
View file

@ -0,0 +1,19 @@
name: active
services:
paperless-ai:
image: clusterzx/paperless-ai:3.0.7
paperless-broker:
image: redis:8.0.3-alpine
paperless-gotenberg:
image: gotenberg/gotenberg:8.21.1
paperless-ollama:
image: ollama/ollama:0.9.6
paperless-tika:
image: apache/tika:3.2.1.0-full
paperless-webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:2.17.1

7
compose/pdf.yml Normal file
View file

@ -0,0 +1,7 @@
name: active
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:1.0.2-fat
networks:
local-network:

7
compose/pinyin-anki.yml Normal file
View file

@ -0,0 +1,7 @@
name: active
services:
pinyin-anki:
image: ghcr.io/schmelczer/pinyin:latest
networks:
local-network:

16
compose/plausible.yml Normal file
View file

@ -0,0 +1,16 @@
# copied from https://github.com/plausible/hosting/tree/master
name: active
services:
plausible:
image: plausible/analytics:v2.0.0
plausible_db:
image: postgres:14-alpine
plausible_events_db:
image: clickhouse/clickhouse-server:23.3.7.5-alpine
networks:
local-network:
plausible-network:
internal: true

54
compose/projects.yml Normal file
View file

@ -0,0 +1,54 @@
name: active
services:
declared:
build:
context: /volumes/declared-server
container_name: declared
environment:
NODE_ENV: production
TZ: $TIME_ZONE
networks:
- local-network
restart: unless-stopped
user: "1000"
init: true
tty: true
life-towers:
build:
context: /volumes/life-towers
container_name: life_towers
depends_on:
- towers-db
environment:
TZ: $TIME_ZONE
networks:
- towers-network
- local-network
restart: unless-stopped
user: "1000"
init: true
tty: true
towers-db:
image: postgres:15.3-alpine3.18
container_name: towers_db
volumes:
- /volumes/life-towers/db:/var/lib/postgresql/data
- /volumes/life-towers/src/schema.sql:/docker-entrypoint-initdb.d/init.sql
environment:
POSTGRES_USER: storebackend
POSTGRES_PASSWORD: UKLpn6y4j4AjmBuB
POSTGRES_DB: store
TZ: $TIME_ZONE
networks:
- towers-network
restart: unless-stopped
user: "1000"
init: true
tty: true
networks:
local-network:
towers-network:
internal: true

4
compose/smtp.yml Normal file
View file

@ -0,0 +1,4 @@
name: active
services:
smtp:
image: bytemark/smtp:latest

33
compose/ssh.yml Normal file
View file

@ -0,0 +1,33 @@
name: active
services:
ssh:
build:
context: /volumes/stack
container_name: ssh
volumes:
- /volumes:/volumes
- /:/host
- /volumes/stack:/root/stack
- /volumes/stack/.zsh_history:/root/.zsh_history
- /volumes/stack/.zshrc:/root/.zshrc
- /var/run/docker.sock:/var/run/docker.sock
environment:
TZ: $TIME_ZONE
network_mode: host
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
cap_add:
- SYS_ADMIN
- SYS_RAWIO
deploy:
resources:
limits:
cpus: "12"
memory: 4G
devices:
- "/dev/nvme0n1"
tty: true
networks:
local-network:

18
compose/stack.yml Normal file
View file

@ -0,0 +1,18 @@
# The necesseary services for keeping the stack running.
# The goal is to keep the number of this to a minimum as to decrease overhead
# and avoid unneeded complexity.
name: active
services:
dozzle:
image: amir20/dozzle:v8.13.6
uptime-kuma:
image: louislam/uptime-kuma:1.23.16
homepage:
image: ghcr.io/gethomepage/homepage:feature-deps-180425
networks:
local-network:

4
compose/vaultwarden.yml Normal file
View file

@ -0,0 +1,4 @@
name: active
services:
vaultwarden:
image: vaultwarden/server:1.34.1

4
compose/wireguard.yml Normal file
View file

@ -0,0 +1,4 @@
name: active
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy:15.1.0

46
config.example.yaml Normal file
View file

@ -0,0 +1,46 @@
# Docker Compose Updater Configuration
# This configuration determines how the updater behaves
# Paths to scan for Docker Compose files
compose_paths:
- "/app/compose-files"
- "./docker-compose.yml"
- "./compose.yml"
# Cron expression for when to run updates
# Default: Daily at 2 AM UTC
schedule: "0 0 2 * * *"
# Registry configurations
# All registries use the standard Docker Registry v2 API
registries:
"docker.io":
url: "https://registry-1.docker.io"
# Docker Hub uses the standard registry API endpoint
"ghcr.io":
url: "https://ghcr.io"
auth_token: "${GITHUB_TOKEN}"
# GitHub token must have 'read:packages' scope and access to the target repositories
"registry.gitlab.com":
url: "https://registry.gitlab.com"
auth_token: "${GITLAB_TOKEN}"
# Add any custom registry following the same pattern:
# "my-registry.company.com":
# url: "https://my-registry.company.com"
# auth_token: "${CUSTOM_REGISTRY_TOKEN}"
# Update strategy
# Options: LatestPatchOfPreviousMinor (default), LatestPatchOfPreviousMinor, Latest
update_strategy: "LatestPatchOfPreviousMinor"
# Images to ignore (substring matching)
ignore_images:
- "localhost"
- "127.0.0.1"
- "local/"
# Dry run mode - if true, no files will be modified
dry_run: false

22
demo-config.yaml Normal file
View file

@ -0,0 +1,22 @@
compose_paths:
- "/app/compose-files"
- "./test-docker-compose.yml"
schedule: "0 0 2 * * *"
registries:
"docker.io":
url: "https://registry-1.docker.io"
"ghcr.io":
url: "https://ghcr.io"
auth_token: "${GITHUB_TOKEN}"
update_strategy: "LatestPatchOfPreviousMinor"
ignore_images:
- "localhost"
- "127.0.0.1"
- "local/"
dry_run: true

44
docker-compose.yml Normal file
View file

@ -0,0 +1,44 @@
version: '3.8'
services:
docker-compose-updater:
image: ghcr.io/your-username/docker-compose-updater:latest
container_name: docker-compose-updater
restart: unless-stopped
environment:
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITLAB_TOKEN=${GITLAB_TOKEN}
- RUST_LOG=info
- TZ=UTC
volumes:
# Mount Docker socket for container management
- /var/run/docker.sock:/var/run/docker.sock:ro
# Mount directory containing your compose files
- ./compose-files:/app/compose-files:rw
# Mount configuration
- ./config.yaml:/app/config/config.yaml:ro
# Optional: Mount additional compose files
- ./docker-compose.yml:/app/docker-compose.yml:rw
ports:
- "8080:8080"
networks:
- updater-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
updater-network:
driver: bridge

2
src/compose.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod parser;
pub mod updater;

186
src/compose/parser.rs Normal file
View file

@ -0,0 +1,186 @@
use crate::registry::ImageRef;
use anyhow::Result;
use regex::Regex;
use std::fs;
use tracing::{info, warn};
#[derive(Debug, Clone)]
pub struct ServiceImage {
pub service_name: String,
pub image_ref: ImageRef,
pub original_line: String,
pub line_number: usize,
}
pub struct ComposeFile {
pub content: String,
pub services: Vec<ServiceImage>,
}
pub struct ComposeParser;
impl ComposeParser {
pub fn new() -> Self {
Self
}
pub fn parse_file(&self, file_path: &str) -> Result<ComposeFile> {
let content = fs::read_to_string(file_path)?;
let services = self.extract_services(&content)?;
Ok(ComposeFile { content, services })
}
fn extract_services(&self, content: &str) -> Result<Vec<ServiceImage>> {
let mut services = Vec::new();
let lines: Vec<&str> = content.lines().collect();
let mut in_services = false;
let mut current_service: Option<String> = None;
let image_regex = Regex::new(r#"^\s*image:\s*(?:["']([^"']+)["']|([^\s#]+))\s*(#.*)?$"#)?;
for (line_number, line) in lines.iter().enumerate() {
if line.trim_start().starts_with("services:") {
in_services = true;
continue;
}
if in_services
&& line.chars().next().is_some_and(|c| c.is_alphabetic())
&& !line.starts_with(" ")
{
in_services = false;
current_service = None;
continue;
}
if in_services {
if let Some(service_name) = self.extract_service_name(line) {
current_service = Some(service_name);
continue;
}
if let Some(ref service_name) = current_service {
if let Some(image_ref) = self.extract_image_from_line(line, &image_regex)? {
info!(
"Found service '{}' with image '{}'",
service_name, image_ref
);
services.push(ServiceImage {
service_name: service_name.clone(),
image_ref,
original_line: line.to_string(),
line_number,
});
}
}
}
}
Ok(services)
}
fn extract_service_name(&self, line: &str) -> Option<String> {
let trimmed = line.trim_start();
let indent_level = line.len() - trimmed.len();
if indent_level > 0 && indent_level <= 8 && trimmed.ends_with(':') && !trimmed.contains(' ')
{
let potential_service = trimmed.trim_end_matches(':');
if !potential_service.is_empty()
&& potential_service
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
return Some(potential_service.to_string());
}
}
None
}
fn extract_image_from_line(&self, line: &str, image_regex: &Regex) -> Result<Option<ImageRef>> {
let trimmed = line.trim_start();
let indent_level = line.len() - trimmed.len();
if indent_level > 2 && trimmed.starts_with("image:") {
if let Some(captures) = image_regex.captures(line) {
let image_str = captures
.get(1)
.or_else(|| captures.get(2))
.unwrap()
.as_str();
if image_str.find('@').is_some() {
info!("Found digest in image string, skipping: {}", image_str);
return Ok(None);
}
if image_str.trim().is_empty()
|| image_str.trim() == "\"\""
|| image_str.trim() == "''"
{
info!("Empty image string found in line: {}, skipping", line);
return Ok(None);
}
match ImageRef::parse(image_str) {
Ok(image_ref) => Ok(Some(image_ref)),
Err(e) => {
warn!("Failed to parse image '{}': {}", image_str, e);
Ok(None)
}
}
} else {
Ok(None)
}
} else {
Ok(None)
}
}
}
impl Default for ComposeParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_parse_compose_file() {
let compose_content = r#"
version: '3.8'
services:
web:
image: nginx:1.21.0 # Web server
ports:
- "80:80"
db:
image: postgres:13.7
environment:
POSTGRES_DB: myapp
redis:
image: redis:6.2-alpine
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let parser = ComposeParser::new();
let result = parser
.parse_file(temp_file.path().to_str().unwrap())
.unwrap();
assert_eq!(result.services.len(), 3);
assert_eq!(result.services[0].service_name, "web");
assert_eq!(result.services[0].image_ref.name, "nginx");
assert_eq!(result.services[0].image_ref.tag, "1.21.0");
}
}

293
src/compose/updater.rs Normal file
View file

@ -0,0 +1,293 @@
use super::parser::{ComposeFile, ComposeParser, ServiceImage};
use crate::config::Config;
use crate::registry::Client as RegistryClient;
use crate::strategy::create_selector;
use crate::version::parse_version_tag;
use anyhow::{anyhow, Result};
use regex::Regex;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
pub struct ComposeUpdater {
config: Config,
registry_client: RegistryClient,
parser: ComposeParser,
}
impl ComposeUpdater {
pub fn new(config: Config) -> Self {
let registry_client = RegistryClient::new(config.clone());
let parser = ComposeParser::new();
Self {
config,
registry_client,
parser,
}
}
pub async fn update_all_compose_files(&self) -> Result<Vec<String>> {
let mut updated_files = Vec::new();
for compose_path in &self.config.compose_paths {
let compose_files = self.find_compose_files(compose_path)?;
for file_path in compose_files {
let updated = self.update_compose_file(&file_path).await?;
if updated {
updated_files.push(file_path);
}
}
}
Ok(updated_files)
}
pub fn parse_compose_file(&self, file_path: &str) -> Result<ComposeFile> {
self.parser.parse_file(file_path)
}
async fn update_compose_file(&self, file_path: &str) -> Result<bool> {
info!("Processing compose file: {}", file_path);
let compose_file = self.parse_compose_file(file_path)?;
let mut updated = false;
let mut new_content = compose_file.content.clone();
for service in &compose_file.services {
if self.config.is_image_ignored(&service.image_ref.to_string()) {
info!("Skipping ignored image: {}", service.image_ref.to_string());
continue;
}
match self.update_service_image(service).await {
Ok(Some(new_image)) => {
new_content =
self.replace_image_in_content(&new_content, service, &new_image)?;
updated = true;
info!(
"Updated {}: {} -> {}",
service.service_name,
service.image_ref.to_string(),
new_image
);
}
Ok(None) => {
debug!(
"No update needed for {}: {}",
service.service_name,
service.image_ref.to_string()
);
}
Err(e) => {
return Err(
e.context(format!("Failed to update service {}", service.service_name))
);
}
}
}
if updated {
self.write_updated_content(file_path, new_content)?;
}
Ok(updated)
}
fn write_updated_content(&self, file_path: &str, content: String) -> Result<()> {
if !self.config.dry_run {
fs::write(file_path, content)?;
info!("Updated compose file: {}", file_path);
} else {
info!("Dry run: Would update compose file: {}", file_path);
}
Ok(())
}
async fn update_service_image(&self, service: &ServiceImage) -> Result<Option<String>> {
info!(
"Checking for updates for service: {} (current image: {})",
service.service_name,
service.image_ref.to_string()
);
let (current_version, current_prefix, current_suffix, _) =
parse_version_tag(&service.image_ref.tag);
let available_versions = self
.registry_client
.get_available_versions(&service.image_ref)
.await?;
if available_versions.is_empty() {
warn!("No versions available for selection");
return Ok(None);
}
let selector = create_selector(&self.config.update_strategy);
if let Some(target_version_info) =
selector.select_target_version(&available_versions, current_prefix, current_suffix)
{
// Only update if the target version is different AND higher than the current version
// This prevents downgrades
if Some(target_version_info.version.clone()) != current_version {
if let Some(ref current_ver) = current_version {
if target_version_info.version < *current_ver {
info!(
"Skipping downgrade from {} to {}",
current_ver, target_version_info.version
);
return Ok(None);
}
}
let mut new_image_ref = service.image_ref.clone();
new_image_ref.tag = target_version_info.to_string();
return Ok(Some(new_image_ref.to_string()));
}
}
Ok(None)
}
pub fn replace_image_in_content(
&self,
content: &str,
service: &ServiceImage,
new_image: &str,
) -> Result<String> {
let image_regex =
Regex::new(r#"^(\s*image:\s*)(?:["']([^"']+)["']|([^\s#]+))(\s*(?:#.*)?)$"#)?;
if let Some(captures) = image_regex.captures(&service.original_line) {
let prefix = captures.get(1).unwrap().as_str();
let _old_image = captures
.get(2)
.or_else(|| captures.get(3))
.unwrap()
.as_str();
let suffix = captures.get(4).unwrap().as_str();
let image_part = if captures.get(2).is_some() {
format!("\"{new_image}\"")
} else {
new_image.to_string()
};
let new_line = format!("{prefix}{image_part}{suffix}");
let lines: Vec<&str> = content.lines().collect();
if service.line_number < lines.len()
&& lines[service.line_number] == service.original_line
{
let mut result_lines = lines;
result_lines[service.line_number] = &new_line;
let mut result = result_lines.join("\n");
if content.ends_with('\n') {
result.push('\n');
}
Ok(result)
} else {
Err(anyhow!(
"Line number mismatch or content changed for service: {}",
service.service_name
))
}
} else {
Err(anyhow!(
"Could not parse image line: {}",
service.original_line
))
}
}
fn find_compose_files(&self, path: &Path) -> Result<Vec<String>> {
let mut visited = HashSet::new();
self.find_compose_files_recursive(path, &mut visited)
}
fn find_compose_files_recursive(
&self,
path: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<Vec<String>> {
let canonical_path = match path.canonicalize() {
Ok(p) => p,
Err(e) => {
warn!("Failed to canonicalize path {}: {}", path.display(), e);
return Ok(Vec::new());
}
};
if !visited.insert(canonical_path.clone()) {
return Ok(Vec::new());
}
let mut compose_files = Vec::new();
if path.is_file() {
if self.is_compose_file(path)? {
compose_files.push(path.to_string_lossy().to_string());
}
} else if path.is_dir() {
for entry in fs::read_dir(path)? {
let entry = entry?;
let entry_path = entry.path();
if entry_path.is_file() && self.is_compose_file(&entry_path)? {
compose_files.push(entry_path.to_string_lossy().to_string());
} else if entry_path.is_dir() {
compose_files.extend(self.find_compose_files_recursive(&entry_path, visited)?);
}
}
}
Ok(compose_files)
}
fn is_compose_file(&self, path: &Path) -> Result<bool> {
let filename = path
.file_name()
.ok_or_else(|| anyhow!("Invalid file path: {:?}", path))?
.to_string_lossy();
Ok(filename.ends_with(".yml") || filename.ends_with(".yaml"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, UpdateStrategy};
use std::io::Write;
use tempfile::NamedTempFile;
#[tokio::test]
async fn test_prevents_downgrade() {
let mut config = Config::default();
config.update_strategy = UpdateStrategy::Latest;
config.dry_run = true;
let updater = ComposeUpdater::new(config);
// Create a temporary compose file with a higher version
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "services:\n web:\n image: nginx:1.25.0").unwrap();
let file_path = temp_file.path().to_str().unwrap();
let compose_file = updater.parse_compose_file(file_path).unwrap();
assert_eq!(compose_file.services.len(), 1);
let service = &compose_file.services[0];
// Mock a scenario where the strategy selects a lower version
// This would happen if available versions only include older versions
let (current_version, _, _, _) = parse_version_tag(&service.image_ref.tag);
assert!(current_version.is_some());
// The actual test would need mocked registry responses, but we can verify
// the logic by checking that current version is properly extracted
assert_eq!(current_version.unwrap().to_string(), "1.25.0");
}
}

105
src/config.rs Normal file
View file

@ -0,0 +1,105 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::info;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub compose_paths: Vec<PathBuf>,
#[serde(default)]
pub schedule: String,
#[serde(default)]
pub registries: HashMap<String, RegistryConfig>,
#[serde(default)]
pub update_strategy: UpdateStrategy,
#[serde(default)]
pub ignore_images: Vec<String>,
#[serde(default)]
pub dry_run: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryConfig {
pub url: String,
pub auth_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub enum UpdateStrategy {
#[default]
LatestPatchOfPreviousMinor,
Latest,
}
impl Default for Config {
fn default() -> Self {
let mut registries = HashMap::new();
registries.insert(
"docker.io".to_string(),
RegistryConfig {
url: "https://registry-1.docker.io".to_string(),
auth_token: None,
},
);
registries.insert(
"ghcr.io".to_string(),
RegistryConfig {
url: "https://ghcr.io".to_string(),
auth_token: std::env::var("GITHUB_TOKEN").ok(),
},
);
Self {
compose_paths: vec![PathBuf::from(".")],
schedule: "0 0 2 * * *".to_string(), // Daily at 2 AM
registries,
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec![],
dry_run: false,
}
}
}
impl Config {
pub fn load(config_path: PathBuf) -> Result<Self> {
info!("Loading configuration from {}", config_path.display());
let content = std::fs::read_to_string(config_path)?;
let expanded_content = Self::expand_env_vars(&content);
let mut config: Self = serde_yaml::from_str(&expanded_content)?;
config.resolve_env_tokens();
Ok(config)
}
/// Expand environment variable placeholders like ${VAR} in the config content
pub fn expand_env_vars(content: &str) -> String {
let env_var_pattern = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
env_var_pattern
.replace_all(content, |caps: &regex::Captures| {
let var_name = &caps[1];
std::env::var(var_name).unwrap_or_else(|_| format!("${{{var_name}}}"))
})
.to_string()
}
/// Resolve environment variable tokens after deserialization
fn resolve_env_tokens(&mut self) {
for registry_config in self.registries.values_mut() {
if let Some(token) = &registry_config.auth_token {
if token.starts_with("$") {
// Handle direct env var references like "$GITHUB_TOKEN"
let env_var_name = token.trim_start_matches('$');
registry_config.auth_token = std::env::var(env_var_name).ok();
}
}
}
}
pub fn is_image_ignored(&self, image: &str) -> bool {
self.ignore_images
.iter()
.any(|ignored| image.contains(ignored))
}
}

114
src/health.rs Normal file
View file

@ -0,0 +1,114 @@
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::time::timeout;
use tracing::{info, warn};
pub struct HealthServer {
last_update_success: Arc<Mutex<bool>>,
}
#[derive(Clone)]
pub struct HealthHandle {
last_update_success: Arc<Mutex<bool>>,
}
impl Default for HealthServer {
fn default() -> Self {
Self::new().0
}
}
impl HealthServer {
pub fn new() -> (Self, HealthHandle) {
let last_update_success = Arc::new(Mutex::new(true));
let server = Self {
last_update_success: last_update_success.clone(),
};
let handle = HealthHandle {
last_update_success,
};
(server, handle)
}
pub async fn start(self) -> anyhow::Result<()> {
let listener = TcpListener::bind("0.0.0.0:8080").await?;
info!("Health server listening on port 8080");
loop {
let (mut socket, _) = listener.accept().await?;
let health_status = self.last_update_success.clone();
tokio::spawn(async move {
// Set timeout for the entire request handling (5 seconds)
let handle_request = async {
let mut buffer = [0; 1024];
// Read with timeout to prevent hanging connections
match timeout(Duration::from_secs(5), socket.read(&mut buffer)).await {
Ok(Ok(_)) => {
// Successfully read request (we don't need to parse it for health check)
}
Ok(Err(_)) | Err(_) => {
warn!("Health check request read timeout or error");
return;
}
}
// Safely access health status without panicking
let is_healthy = match health_status.lock() {
Ok(status) => *status,
Err(_) => {
warn!("Health status mutex poisoned, defaulting to unhealthy");
false
}
};
let (status_line, json_body) = if is_healthy {
("HTTP/1.1 200 OK", "{\"status\":\"healthy\"}")
} else {
(
"HTTP/1.1 503 Service Unavailable",
"{\"status\":\"unhealthy\"}",
)
};
let response = format!(
"{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status_line,
json_body.len(),
json_body
);
let _ = timeout(
Duration::from_secs(5),
socket.write_all(response.as_bytes()),
)
.await;
};
// Overall timeout for the entire connection handling
let _ = timeout(Duration::from_secs(10), handle_request).await;
});
}
}
}
impl HealthHandle {
pub fn set_health_status(&self, is_healthy: bool) {
if let Ok(mut status) = self.last_update_success.lock() {
*status = is_healthy;
}
}
pub fn report_update_success(&self) {
info!("Update succeeded - marking health as healthy");
self.set_health_status(true);
}
pub fn report_update_failure(&self) {
info!("Update failed - marking health as unhealthy");
self.set_health_status(false);
}
}

7
src/lib.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod compose;
pub mod config;
pub mod health;
pub mod registry;
pub mod scheduler;
pub mod strategy;
pub mod version;

77
src/main.rs Normal file
View file

@ -0,0 +1,77 @@
use std::path::PathBuf;
use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing::{info, Level};
mod compose;
mod config;
mod health;
mod registry;
mod scheduler;
mod strategy;
mod version;
use config::Config;
use health::HealthServer;
use scheduler::Scheduler;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Path to the configuration file
#[arg(short, long)]
config: PathBuf,
/// Verbose output (-v, -vv, -vvv for increasing verbosity)
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Start the updater service
Start,
/// Run a one-time update
Update,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let log_level = match cli.verbose {
0 => Level::INFO,
1 => Level::DEBUG,
_ => Level::TRACE,
};
tracing_subscriber::fmt()
.with_max_level(log_level)
.with_timer(tracing_subscriber::fmt::time::SystemTime)
.with_target(false)
.init();
let config = Config::load(cli.config)?;
info!(
"Starting Docker Compose Updater v{}",
env!("CARGO_PKG_VERSION")
);
match cli.command {
Commands::Start => {
let (health_server, health_handle) = HealthServer::new();
let scheduler = Scheduler::new(config.clone(), Some(health_handle))?;
tokio::try_join!(health_server.start(), scheduler.start())?;
}
Commands::Update => {
let scheduler = Scheduler::new(config, None)?;
scheduler.run_once().await?;
}
}
Ok(())
}

605
src/registry.rs Normal file
View file

@ -0,0 +1,605 @@
use crate::config::{Config, RegistryConfig};
use crate::version::VersionInfo;
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use reqwest::{Client as HttpClient, Response, StatusCode};
use serde::Deserialize;
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, warn};
const PAGE_SIZE: usize = 500;
const MAX_RETRY_ATTEMPTS: u32 = 5;
const INITIAL_RETRY_DELAY_SECS: u64 = 1;
#[derive(Debug, Clone)]
pub struct ImageRef {
pub registry: String,
pub namespace: Option<String>,
pub name: String,
pub tag: String,
}
impl ImageRef {
pub fn parse(image: &str) -> Result<Self> {
let (image_part, tag) = if let Some(last_delim) = image.rfind(':') {
(&image[..last_delim], &image[last_delim + 1..])
} else {
(image, "latest")
};
let registry_parts: Vec<&str> = image_part.split('/').collect();
let (registry, namespace, name) = match registry_parts.len() {
0 => return Err(anyhow!("Invalid image format: {}", image)),
1 => ("docker.io".to_string(), None, registry_parts[0].to_string()),
2 => {
if registry_parts[0].contains('.') || registry_parts[0].contains(':') {
(
registry_parts[0].to_string(),
None,
registry_parts[1].to_string(),
)
} else {
(
"docker.io".to_string(),
Some(registry_parts[0].to_string()),
registry_parts[1].to_string(),
)
}
}
_ => {
// 3 or more parts: first is registry, last is name, everything in between is namespace
let registry = registry_parts[0].to_string();
let name = registry_parts[registry_parts.len() - 1].to_string();
let namespace = Some(registry_parts[1..registry_parts.len() - 1].join("/"));
(registry, namespace, name)
}
};
Ok(ImageRef {
registry,
namespace,
name,
tag: tag.to_string(),
})
}
}
impl std::fmt::Display for ImageRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let base = match &self.namespace {
Some(ns) => format!("{}/{}/{}", self.registry, ns, self.name),
None => format!("{}/{}", self.registry, self.name),
};
if self.registry == "docker.io" {
match &self.namespace {
Some(ns) => write!(f, "{}/{}:{}", ns, self.name, self.tag),
None => write!(f, "{}:{}", self.name, self.tag),
}
} else {
write!(f, "{}:{}", base, self.tag)
}
}
}
/// Docker Hub API response for tag list
#[derive(Deserialize)]
struct DockerHubTagsResponse {
results: Vec<DockerHubTag>,
next: Option<String>,
}
#[derive(Deserialize)]
struct DockerHubTag {
name: String,
}
/// Unified registry client with hybrid approach
/// Uses Docker Hub's public API for Docker Hub, standard v2 API for others
pub struct Client {
http_client: HttpClient,
config: Config,
}
impl Client {
pub fn new(config: Config) -> Self {
Self {
http_client: HttpClient::new(),
config,
}
}
/// Parse Retry-After header to determine wait duration
/// Supports both delay-seconds (integer) and HTTP-date formats
fn parse_retry_after(&self, retry_after: &str) -> Option<Duration> {
// Try parsing as seconds first
if let Ok(seconds) = retry_after.parse::<u64>() {
return Some(Duration::from_secs(seconds));
}
// Try parsing as HTTP date (RFC 2822 or RFC 3339)
if let Ok(date) = DateTime::parse_from_rfc2822(retry_after) {
let now = Utc::now();
let wait_time = date.signed_duration_since(now);
if wait_time.num_seconds() > 0 {
return Some(Duration::from_secs(wait_time.num_seconds() as u64));
}
}
None
}
/// Perform HTTP request with retry logic for rate limiting (429)
async fn request_with_retry<F, Fut>(
&self,
request_fn: F,
operation_name: &str,
) -> Result<Response>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<Response, reqwest::Error>>,
{
let mut attempt = 0;
let mut delay = Duration::from_secs(INITIAL_RETRY_DELAY_SECS);
loop {
let response = request_fn().await?;
if response.status() == StatusCode::TOO_MANY_REQUESTS {
attempt += 1;
if attempt > MAX_RETRY_ATTEMPTS {
return Err(anyhow!(
"{} failed: Rate limited (429) after {} attempts",
operation_name,
MAX_RETRY_ATTEMPTS
));
}
// Check for Retry-After header
let wait_duration = if let Some(retry_after) = response
.headers()
.get("retry-after")
.and_then(|h| h.to_str().ok())
{
self.parse_retry_after(retry_after).unwrap_or(delay)
} else {
delay
};
warn!(
"{} rate limited (429), attempt {}/{}, waiting {:?} before retry",
operation_name, attempt, MAX_RETRY_ATTEMPTS, wait_duration
);
sleep(wait_duration).await;
// Exponential backoff for next attempt if no Retry-After header
delay = delay.saturating_mul(2);
} else {
return Ok(response);
}
}
}
fn get_registry_config(&self, registry: &str) -> Result<&RegistryConfig> {
self.config
.registries
.get(registry)
.ok_or_else(|| anyhow!("Unknown registry: {}", registry))
}
fn build_repository_path(&self, image_ref: &ImageRef) -> String {
match &image_ref.namespace {
Some(namespace) => format!("{}/{}", namespace, image_ref.name),
None => {
if image_ref.registry == "docker.io" {
// For Docker Hub official images, use 'library' namespace in public API
format!("library/{}", image_ref.name)
} else {
// For other registries, no namespace means just the image name
image_ref.name.clone()
}
}
}
}
fn parse_dockerhub_response(
&self,
response_text: &str,
) -> Result<(Vec<VersionInfo>, Option<String>)> {
let dockerhub_response: DockerHubTagsResponse = serde_json::from_str(response_text)
.map_err(|e| anyhow!("Failed to parse Docker Hub response: {}", e))?;
let mut versions = Vec::new();
for tag in dockerhub_response.results {
if let Some(version_info) = VersionInfo::from_tag(&tag.name) {
versions.push(version_info);
}
}
Ok((versions, dockerhub_response.next))
}
fn parse_v2_response(&self, response_text: &str) -> Result<Vec<VersionInfo>> {
debug!("Parsing registry v2 response: {}", response_text);
let tags_response: serde_json::Value = serde_json::from_str(response_text)
.map_err(|e| anyhow!("Failed to parse registry response: {}", e))?;
let mut versions = Vec::new();
if let Some(tags) = tags_response.get("tags").and_then(|t| t.as_array()) {
for tag in tags {
if let Some(tag_str) = tag.as_str() {
if let Some(version_info) = VersionInfo::from_tag(tag_str) {
versions.push(version_info);
}
}
}
}
Ok(versions)
}
pub async fn get_available_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
if image_ref.registry == "docker.io" {
self.get_dockerhub_versions(image_ref).await
} else {
self.get_registry_v2_versions(image_ref).await
}
}
async fn get_dockerhub_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
let repo_path = self.build_repository_path(image_ref);
let mut results = Vec::new();
// Use Docker Hub's public REST API which doesn't require authentication for public repos
let mut url = format!(
"https://hub.docker.com/v2/repositories/{repo_path}/tags/?page_size={PAGE_SIZE}"
);
loop {
debug!("Docker Hub API URL: {}", url);
let url_clone = url.clone();
let response = self
.request_with_retry(
|| async { self.http_client.get(&url_clone).send().await },
"Docker Hub API request",
)
.await?;
debug!("Docker Hub response status: {}", response.status());
if !response.status().is_success() {
return Err(anyhow!(
"Docker Hub request failed with status {}: {}",
response.status(),
response.text().await.unwrap_or_default()
));
}
let response_text = response.text().await?;
let (new_tags, next_page) = self.parse_dockerhub_response(&response_text)?;
results.extend(new_tags);
if let Some(next) = next_page {
url = next;
} else {
break;
}
}
Ok(results)
}
async fn get_registry_v2_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
let registry_config = self.get_registry_config(&image_ref.registry)?;
let repo_path = self.build_repository_path(image_ref);
let mut results = Vec::new();
let mut last_tag: Option<String> = None;
let mut next_url: Option<String> = None;
let mut bearer_token: Option<String> = None;
// Reference: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#endpoints
loop {
// Use next URL from Link header if available, otherwise build URL with last parameter
let url = if let Some(next) = next_url.take() {
format!("{}{next}", registry_config.url)
} else {
format!(
"{}/v2/{repo_path}/tags/list?n={PAGE_SIZE}{}",
registry_config.url,
if let Some(ref last) = last_tag {
format!("&last={last}")
} else {
String::new()
}
)
};
debug!("Registry API URL: {}", url);
// Try unauthenticated request first to potentially get auth challenge
let url_clone = url.clone();
let bearer_token_clone = bearer_token.clone();
let response = self
.request_with_retry(
|| async {
let mut request_builder = self.http_client.get(&url_clone);
if let Some(token) = &bearer_token_clone {
request_builder = request_builder.bearer_auth(token);
}
request_builder.send().await
},
"Registry v2 API request",
)
.await?;
let (new_tags, link_next) = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if let Some(token) = &registry_config.auth_token {
// Try Docker Registry v2 auth flow if we get auth challenge
if let Some(auth_header) = response.headers().get("www-authenticate") {
bearer_token = self
.try_registry_v2_auth(auth_header.to_str().unwrap(), token)
.await?;
continue; // Retry with new token
} else {
return Err(anyhow!(
"Unauthorized request but no WWW-Authenticate header found"
));
}
} else {
return Err(anyhow!("Unauthorized request but no auth token configured"));
}
} else if response.status().is_success() {
// Check for Link header for pagination
let link_next = response
.headers()
.get("link")
.and_then(|h| h.to_str().ok())
.and_then(|h| self.parse_link_header(h));
let response_text = response.text().await?;
let tags = self.parse_v2_response(&response_text)?;
(tags, link_next)
} else {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(anyhow!(
"Registry request failed with status {}: {}",
status,
error_text
));
};
let maybe_last_tag = new_tags.last().map(|v| v.original.clone());
results.extend(new_tags);
// Use Link header next URL if available, otherwise fall back to last parameter pagination
if let Some(next) = link_next {
next_url = Some(next);
} else if let Some(last) = maybe_last_tag {
last_tag = Some(last);
} else {
break;
}
}
Ok(results)
}
async fn try_registry_v2_auth(&self, auth_str: &str, token: &str) -> Result<Option<String>> {
// Parse WWW-Authenticate header to extract auth parameters
let realm = self.extract_auth_param(auth_str, "realm")?;
let service = self.extract_auth_param(auth_str, "service")?;
let scope = self.extract_auth_param(auth_str, "scope")?;
// Request token from auth endpoint
let auth_url = format!("{realm}?service={service}&scope={scope}");
debug!("Getting registry token from: {}", auth_url);
let auth_url_clone = auth_url.clone();
let token_clone = token.to_string();
let token_response = self
.request_with_retry(
|| async {
self.http_client
.get(&auth_url_clone)
.basic_auth("token", Some(&token_clone))
.send()
.await
},
"Registry auth token request",
)
.await?;
if !token_response.status().is_success() {
return Err(anyhow!(
"Failed to get token: {} - {}",
token_response.status(),
token_response.text().await.unwrap_or_default()
));
}
let token_json: serde_json::Value = token_response.json().await?;
if let Some(registry_token) = token_json.get("token").and_then(|t| t.as_str()) {
return Ok(Some(registry_token.to_string()));
}
Err(anyhow!("Auth flow didn't work, caller can try fallback"))
}
fn extract_auth_param(&self, auth_str: &str, param: &str) -> Result<String> {
let pattern = format!(r#"{param}="([^"]+)""#);
let re = regex::Regex::new(&pattern)?;
re.captures(auth_str)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
.ok_or_else(|| anyhow!("Missing {} in auth challenge", param))
}
fn parse_link_header(&self, link_header: &str) -> Option<String> {
// Parse RFC 5988 Link header to find "next" relation
// Format: <https://example.com/page2>; rel="next", <https://example.com/last>; rel="last"
for link in link_header.split(',') {
let parts: Vec<&str> = link.trim().split(';').collect();
if parts.len() >= 2 {
let url = parts[0].trim();
if url.starts_with('<') && url.ends_with('>') {
let url = &url[1..url.len() - 1]; // Remove < and >
for param in &parts[1..] {
let param = param.trim();
if param == "rel=\"next\"" || param == "rel=next" {
return Some(url.to_string());
}
}
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_image_ref_parsing() {
let test_cases = vec![
("nginx:1.21.0", "docker.io", None, "nginx", "1.21.0"),
(
"ubuntu/nginx:1.21.0",
"docker.io",
Some("ubuntu"),
"nginx",
"1.21.0",
),
(
"ghcr.io/user/app:v1.0.0",
"ghcr.io",
Some("user"),
"app",
"v1.0.0",
),
(
"localhost:5000/myapp:latest",
"localhost:5000",
None,
"myapp",
"latest",
),
(
"ghcr.io/schmelczer/fizika/fizika-admin:latest",
"ghcr.io",
Some("schmelczer/fizika"),
"fizika-admin",
"latest",
),
(
"registry.example.com/org/team/project/image:v2.0.0",
"registry.example.com",
Some("org/team/project"),
"image",
"v2.0.0",
),
];
for (input, expected_registry, expected_namespace, expected_name, expected_tag) in
test_cases
{
let image_ref = ImageRef::parse(input).unwrap();
assert_eq!(
image_ref.registry, expected_registry,
"Registry mismatch for {}",
input
);
assert_eq!(
image_ref.namespace,
expected_namespace.map(String::from),
"Namespace mismatch for {}",
input
);
assert_eq!(image_ref.name, expected_name, "Name mismatch for {}", input);
assert_eq!(image_ref.tag, expected_tag, "Tag mismatch for {}", input);
}
}
#[test]
fn test_repository_path_building() {
let config = Config::default();
let client = Client::new(config);
// Official Docker Hub image (no namespace)
let image_ref = ImageRef {
registry: "docker.io".to_string(),
namespace: None,
name: "nginx".to_string(),
tag: "latest".to_string(),
};
assert_eq!(client.build_repository_path(&image_ref), "library/nginx");
// Docker Hub with namespace
let image_ref = ImageRef {
registry: "docker.io".to_string(),
namespace: Some("bitnami".to_string()),
name: "nginx".to_string(),
tag: "latest".to_string(),
};
assert_eq!(client.build_repository_path(&image_ref), "bitnami/nginx");
// Custom registry
let image_ref = ImageRef {
registry: "ghcr.io".to_string(),
namespace: Some("user".to_string()),
name: "app".to_string(),
tag: "v1.0.0".to_string(),
};
assert_eq!(client.build_repository_path(&image_ref), "user/app");
}
#[test]
fn test_parse_link_header() {
let config = Config::default();
let client = Client::new(config);
// Test standard Link header with next rel
let link_header = r#"<https://registry.example.com/v2/repo/tags/list?n=100&last=tag99>; rel="next", <https://registry.example.com/v2/repo/tags/list?n=100&last=tag999>; rel="last""#;
let next_url = client.parse_link_header(link_header);
assert_eq!(
next_url,
Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string())
);
// Test Link header without quotes around rel value
let link_header =
r#"<https://registry.example.com/v2/repo/tags/list?n=100&last=tag99>; rel=next"#;
let next_url = client.parse_link_header(link_header);
assert_eq!(
next_url,
Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string())
);
// Test Link header with no next relation
let link_header = r#"<https://registry.example.com/v2/repo/tags/list?n=100&last=tag1>; rel="prev", <https://registry.example.com/v2/repo/tags/list?n=100&last=tag999>; rel="last""#;
let next_url = client.parse_link_header(link_header);
assert_eq!(next_url, None);
// Test empty Link header
let next_url = client.parse_link_header("");
assert_eq!(next_url, None);
// Test malformed Link header
let link_header = "not-a-valid-link-header";
let next_url = client.parse_link_header(link_header);
assert_eq!(next_url, None);
}
}

164
src/scheduler.rs Normal file
View file

@ -0,0 +1,164 @@
use crate::health::HealthHandle;
use crate::{compose::updater::ComposeUpdater, config::Config};
use anyhow::{anyhow, Context, Result};
use cron::Schedule;
use std::str::FromStr;
use std::time::Duration;
use tokio::time::{sleep, Instant};
use tracing::error;
use tracing::info;
pub struct Scheduler {
config: Config,
updater: ComposeUpdater,
schedule: Schedule,
health_handle: Option<HealthHandle>,
}
impl Scheduler {
pub fn new(config: Config, health_handle: Option<HealthHandle>) -> Result<Self> {
let schedule = Schedule::from_str(&config.schedule)
.map_err(|e| anyhow!("Invalid cron expression '{}': {}", config.schedule, e))?;
let updater = ComposeUpdater::new(config.clone());
Ok(Self {
config,
updater,
schedule,
health_handle,
})
}
pub async fn start(&self) -> Result<()> {
info!(
"Starting scheduler with cron expression: {}",
self.config.schedule
);
loop {
if let Err(err) = self.run_update().await.context("Failed to run update") {
error!("{:?}", err)
}
if let Some(next_run) = self.schedule.upcoming(chrono::Utc).take(1).next() {
let now = chrono::Utc::now();
let duration_until_next = next_run.signed_duration_since(now);
if duration_until_next.num_seconds() > 0 {
info!(
"Next update scheduled for: {}",
next_run.format("%Y-%m-%d %H:%M:%S UTC")
);
let sleep_duration =
Duration::from_secs(duration_until_next.num_seconds() as u64);
sleep(sleep_duration).await;
}
}
}
}
pub async fn run_once(&self) -> Result<()> {
info!("Running one-time update");
self.run_update().await
}
async fn run_update(&self) -> Result<()> {
let start_time = Instant::now();
info!("Starting Docker Compose update cycle");
match self.updater.update_all_compose_files().await {
Ok(updated_files) => {
let duration = start_time.elapsed();
if updated_files.is_empty() {
info!(
"Update cycle completed in {:?} - no files updated",
duration
);
} else {
info!(
"Update cycle completed in {:?} - {} {} files:",
duration,
if self.config.dry_run {
"would update"
} else {
"updated"
},
updated_files.len()
);
for file in &updated_files {
info!(" - {}", file);
}
}
// Report success to health monitor
if let Some(ref health) = self.health_handle {
health.report_update_success();
}
Ok(())
}
Err(e) => {
let error = e.context("Failed to update Docker Compose files");
// Report failure to health monitor
if let Some(ref health) = self.health_handle {
health.report_update_failure();
}
Err(error)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, UpdateStrategy};
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn test_scheduler_creation() {
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 0 2 * * *".to_string(),
registries: HashMap::new(),
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec![],
dry_run: true,
};
let scheduler = Scheduler::new(config, None).unwrap();
assert!(scheduler
.schedule
.upcoming(chrono::Utc)
.take(1)
.next()
.is_some());
}
#[test]
fn test_cron_parsing() {
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 30 1 * * *".to_string(), // 1:30 AM daily
registries: HashMap::new(),
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec![],
dry_run: true,
};
let scheduler = Scheduler::new(config, None).unwrap();
// Just verify the scheduler can be created
assert!(scheduler
.schedule
.upcoming(chrono::Utc)
.take(1)
.next()
.is_some());
}
}

238
src/strategy.rs Normal file
View file

@ -0,0 +1,238 @@
use crate::config::UpdateStrategy;
use crate::version::VersionInfo;
use tracing::{info, warn};
pub fn create_selector(strategy: &UpdateStrategy) -> Box<dyn VersionSelector> {
match strategy {
UpdateStrategy::Latest => Box::new(LatestVersionSelector),
UpdateStrategy::LatestPatchOfPreviousMinor => Box::new(SmartPreviousMinorSelector),
}
}
pub trait VersionSelector {
fn select_target_version(
&self,
available: &[VersionInfo],
current_prefix: Option<String>,
current_suffix: Option<String>,
) -> Option<VersionInfo>;
}
pub struct LatestVersionSelector;
impl VersionSelector for LatestVersionSelector {
fn select_target_version(
&self,
available: &[VersionInfo],
current_prefix: Option<String>,
current_suffix: Option<String>,
) -> Option<VersionInfo> {
info!("Using update strategy: LatestVersionSelector");
let versions =
get_filtered_and_soreted_matching_versions(available, current_prefix, current_suffix);
let latest = versions.first().cloned();
if let Some(ref selected) = latest {
info!("Selected {} as latest version", selected.version);
} else {
warn!("No versions available for selection");
}
latest
}
}
pub struct SmartPreviousMinorSelector;
impl VersionSelector for SmartPreviousMinorSelector {
fn select_target_version(
&self,
available: &[VersionInfo],
current_prefix: Option<String>,
current_suffix: Option<String>,
) -> Option<VersionInfo> {
info!("Using update strategy: SmartPreviousMinorSelector");
let versions =
get_filtered_and_soreted_matching_versions(available, current_prefix, current_suffix);
let latest = &versions.first()?.version;
info!("Latest version available: {}", latest);
let (target_major, max_minor) = if latest.minor == 0 {
// If current minor is 0, look for the latest minor of the previous major
if latest.major == 0 {
info!("Cannot go to previous version of 0.0.x, skipping update");
return None;
}
info!("The latest version has a minor version of 0, looking for the latest minor of the previous major");
(latest.major - 1, None)
} else {
info!("The latest version has a minor version of {}, looking for the latest patch of the previous minor", latest.minor);
(latest.major, Some(latest.minor - 1))
};
let selected = versions
.iter()
.find(|v| {
v.version.major == target_major && max_minor.is_none_or(|m| v.version.minor <= m)
})
.cloned();
if let Some(ref selected) = selected {
info!("Selected {} as latest version", selected.version);
} else {
warn!("No versions available for selection");
}
selected
}
}
fn get_filtered_and_soreted_matching_versions(
available: &[VersionInfo],
current_prefix: Option<String>,
current_suffix: Option<String>,
) -> Vec<VersionInfo> {
let mut sorted_versions = available.to_vec();
sorted_versions.sort_by(|a, b| b.version.cmp(&a.version));
info!(
"Available versions for selection: {}",
sorted_versions
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ")
);
let filtered_versions: Vec<VersionInfo> = sorted_versions
.into_iter()
.filter(|v| v.prefix == current_prefix && v.suffix == current_suffix)
.collect();
info!(
"Filtered versions with matching prefix and suffix: {}",
filtered_versions
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ")
);
filtered_versions
}
#[cfg(test)]
mod tests {
use semver::Version;
use super::*;
#[test]
fn test_version_strategy_latest_patch_of_previous_minor() {
let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor);
let available = vec![
VersionInfo::from_tag("2.0.0").unwrap(),
VersionInfo::from_tag("1.3.0").unwrap(),
VersionInfo::from_tag("1.2.5").unwrap(),
VersionInfo::from_tag("1.1.5").unwrap(),
VersionInfo::from_tag("1.1.4").unwrap(),
];
let target = selector.select_target_version(&available, None, None);
assert_eq!(
target.map(|v| v.version),
Some(Version::parse("1.3.0").unwrap())
);
}
#[test]
fn test_version_strategy_latest_patch() {
let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor);
let available = vec![
VersionInfo::from_tag("1.3.0").unwrap(),
VersionInfo::from_tag("1.2.5").unwrap(),
VersionInfo::from_tag("1.1.5").unwrap(),
];
let target = selector.select_target_version(&available, None, None);
assert_eq!(
target.map(|v| v.version),
Some(Version::parse("1.2.5").unwrap())
);
let available_far_ahead = vec![
VersionInfo::from_tag("2.0.0").unwrap(),
VersionInfo::from_tag("1.4.0").unwrap(),
VersionInfo::from_tag("1.2.5").unwrap(),
VersionInfo::from_tag("1.1.5").unwrap(),
];
let target = selector.select_target_version(&available_far_ahead, None, None);
assert_eq!(
target.map(|v| v.version),
Some(Version::parse("1.4.0").unwrap())
);
}
#[test]
fn test_version_strategy_latest() {
let selector = create_selector(&UpdateStrategy::Latest);
let available = vec![
VersionInfo::from_tag("2.0.0").unwrap(),
VersionInfo::from_tag("1.2.5").unwrap(),
VersionInfo::from_tag("1.1.5").unwrap(),
];
let target = selector.select_target_version(&available, None, None);
assert_eq!(
target.map(|v| v.version),
Some(Version::parse("2.0.0").unwrap())
);
}
#[test]
fn test_prefix_and_suffix_matching_in_strategy() {
let selector = create_selector(&UpdateStrategy::Latest);
let current_prefix = Some("v".to_string());
let current_suffix = Some("-alpine".to_string());
let available = vec![
VersionInfo::from_tag("1.3.0-alpine").unwrap(),
VersionInfo::from_tag("v1.4.0").unwrap(),
VersionInfo::from_tag("v1.5.0-alpine").unwrap(),
VersionInfo::from_tag("2.0.0").unwrap(),
];
let target = selector.select_target_version(&available, current_prefix, current_suffix);
assert_eq!(
target.map(|v| v.version),
Some(Version::parse("1.5.0").unwrap())
);
}
#[test]
fn test_cross_major_version_handling() {
let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor);
let current_suffix = Some("-fat".to_string());
let available = vec![
VersionInfo::from_tag("1.0.2-fat").unwrap(),
VersionInfo::from_tag("0.46.2-fat").unwrap(),
VersionInfo::from_tag("0.46.1-fat").unwrap(),
VersionInfo::from_tag("0.45.6-fat").unwrap(),
];
let target = selector.select_target_version(&available, None, current_suffix);
assert!(target.is_some());
assert_eq!(target.unwrap().version, Version::parse("0.46.2").unwrap());
}
}

187
src/version.rs Normal file
View file

@ -0,0 +1,187 @@
use regex::Regex;
use semver::Version;
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct VersionInfo {
pub version: Version,
pub prefix: Option<String>,
pub suffix: Option<String>,
pub original: String,
}
impl VersionInfo {
/// Parses a container image tag into a VersionInfo struct
///
/// # Examples
///
/// ```
/// use docker_compose_updater::version::VersionInfo;
///
/// let version_info = VersionInfo::from_tag("v1.29.0-alpine-slim").unwrap();
/// assert_eq!(version_info.version.to_string(), "1.29.0");
/// assert_eq!(version_info.prefix, Some("v".to_string()));
/// assert_eq!(version_info.suffix, Some("-alpine-slim".to_string()));
/// ```
pub fn from_tag(tag: &str) -> Option<Self> {
let re = Regex::new(r"^(?P<prefix>.*?)(?P<version>(?:\d+\.)+\d+)(?P<suffix>.*?)$").unwrap();
if let Some(captures) = re.captures(tag) {
let prefix_part = captures.name("prefix").map_or("", |m| m.as_str());
let version_part = captures.name("version").map_or("", |m| m.as_str());
let suffix_part = captures.name("suffix").map_or("", |m| m.as_str());
let version_part = version_part
.split('.')
.chain(std::iter::repeat("0"))
.take(3)
.collect::<Vec<_>>()
.join(".");
if let Ok(version) = Version::parse(&version_part) {
let prefix = if prefix_part.is_empty() {
None
} else {
Some(prefix_part.to_string())
};
let suffix = if suffix_part.is_empty() {
None
} else {
Some(suffix_part.to_string())
};
return Some(Self {
version,
prefix,
suffix,
original: tag.to_string(),
});
}
}
None
}
}
impl PartialEq<Version> for VersionInfo {
fn eq(&self, other: &Version) -> bool {
self.version == *other
}
}
impl fmt::Display for VersionInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.original)
}
}
/// Extracts semantic version, prefix and suffix from a container image tag
///
/// # Examples
///
/// ```
/// use docker_compose_updater::version::parse_version_tag;
///
/// let (version, prefix, suffix, original) = parse_version_tag("v1.29.0-alpine-slim");
/// assert_eq!(version.unwrap().to_string(), "1.29.0");
/// assert_eq!(prefix, Some("v".to_string()));
/// assert_eq!(suffix, Some("-alpine-slim".to_string()));
/// assert_eq!(original, "v1.29.0-alpine-slim".to_string());
/// ```
pub fn parse_version_tag(tag: &str) -> (Option<Version>, Option<String>, Option<String>, String) {
if let Some(version_info) = VersionInfo::from_tag(tag) {
(
Some(version_info.version),
version_info.prefix,
version_info.suffix,
version_info.original,
)
} else {
(None, None, None, tag.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_version_tag() {
let test_cases = vec![
("1.29.0", "1.29.0", None, None),
("v1.29.0", "1.29.0", Some("v"), None),
("v1.29.0.10.2", "1.29.0", Some("v"), None),
("v1.29", "1.29.0", Some("v"), None),
("release-1.2.3", "1.2.3", Some("release-"), None),
("app_v1.5.0", "1.5.0", Some("app_v"), None),
("1.29.0-alpine", "1.29.0", None, Some("-alpine")),
("15.3-alpine3.18", "15.3.0", None, Some("-alpine3.18")),
(
"v1.29.0-alpine-slim",
"1.29.0",
Some("v"),
Some("-alpine-slim"),
),
(
"release-1.2.3-ubuntu",
"1.2.3",
Some("release-"),
Some("-ubuntu"),
),
("v4.1.1", "4.1.1", Some("v"), None),
(
"v2.1.3-bookworm-perl",
"2.1.3",
Some("v"),
Some("-bookworm-perl"),
),
(
"build123-2.0.1-final",
"2.0.1",
Some("build123-"),
Some("-final"),
),
];
for (input, expected_version, expected_prefix, expected_suffix) in test_cases {
let (version_opt, prefix, suffix, _) = parse_version_tag(input);
match version_opt {
Some(version) => {
assert_eq!(
version.to_string(),
expected_version,
"Version mismatch for {input}"
);
assert_eq!(
prefix,
expected_prefix.map(String::from),
"Prefix mismatch for {input}"
);
assert_eq!(
suffix,
expected_suffix.map(String::from),
"Suffix mismatch for {input}"
);
}
None => {
panic!("Expected version for {input}");
}
}
}
}
#[test]
fn test_non_semver_tags() {
let non_semver_tags = vec!["latest", "stable", "main", "alpine"];
for tag in non_semver_tags {
let (version_opt, _prefix, _suffix, _) = parse_version_tag(tag);
assert!(
version_opt.is_none(),
"Expected no version for non-semver tag: {tag}"
);
}
}
}

259
tests/integration_tests.rs Normal file
View file

@ -0,0 +1,259 @@
use docker_compose_updater::compose::updater::ComposeUpdater;
use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy};
use docker_compose_updater::registry::{Client, ImageRef};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
#[tokio::test]
async fn test_end_to_end_compose_file_parsing_and_updating() {
let compose_content = r#"
version: '3.8'
services:
web:
image: nginx:1.21.0 # Web server
ports:
- "80:80"
db:
image: postgres:13.7
environment:
POSTGRES_DB: myapp
redis:
image: redis:6.2.1-alpine
ports:
- "6379:6379"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let config = create_test_config();
let updater = ComposeUpdater::new(config);
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
assert_eq!(result.services.len(), 3);
assert_eq!(result.services[0].service_name, "web");
assert_eq!(result.services[0].image_ref.name, "nginx");
assert_eq!(result.services[0].image_ref.tag, "1.21.0");
assert_eq!(result.services[0].image_ref.registry, "docker.io");
assert_eq!(result.services[1].service_name, "db");
assert_eq!(result.services[1].image_ref.name, "postgres");
assert_eq!(result.services[1].image_ref.tag, "13.7");
assert_eq!(result.services[2].service_name, "redis");
assert_eq!(result.services[2].image_ref.name, "redis");
assert_eq!(result.services[2].image_ref.tag, "6.2.1-alpine");
}
#[tokio::test]
async fn test_comment_and_formatting_preservation_during_updates() {
let compose_content = r#"
version: '3.8'
services:
web:
image: nginx:1.21.0 # This is a comment
ports:
- "80:80"
# This is another comment
db:
image: "postgres:13.7" # Database comment with quotes
environment:
POSTGRES_DB: myapp
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let config = create_test_config();
let updater = ComposeUpdater::new(config);
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
assert!(result.services[0]
.original_line
.contains("# This is a comment"));
assert!(result.services[1]
.original_line
.contains("# Database comment"));
let new_content = updater
.replace_image_in_content(&result.content, &result.services[0], "nginx:1.20.0")
.unwrap();
assert!(new_content.contains("# This is a comment"));
assert!(new_content.contains("nginx:1.20.0"));
assert!(!new_content.contains("nginx:1.21.0"));
let new_content = updater
.replace_image_in_content(&new_content, &result.services[1], "postgres:14.0")
.unwrap();
assert!(new_content.contains("# Database comment"));
assert!(new_content.contains("\"postgres:14.0\""));
assert!(!new_content.contains("postgres:13.7"));
}
#[tokio::test]
async fn test_empty_compose_paths_handling() {
let config = Config {
compose_paths: vec![],
..create_test_config()
};
let updater = ComposeUpdater::new(config);
let result = updater.update_all_compose_files().await;
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_config_defaults_and_image_filtering() {
let config = Config::default();
assert_eq!(config.schedule, "0 0 2 * * *");
assert_eq!(
config.update_strategy,
UpdateStrategy::LatestPatchOfPreviousMinor
);
assert!(!config.dry_run);
assert!(config.registries.contains_key("docker.io"));
assert!(config.registries.contains_key("ghcr.io"));
let config_with_ignores = Config {
ignore_images: vec!["localhost".to_string(), "127.0.0.1".to_string()],
..Config::default()
};
assert!(config_with_ignores.is_image_ignored("localhost:5000/myapp:latest"));
assert!(config_with_ignores.is_image_ignored("127.0.0.1:5000/myapp:latest"));
assert!(!config_with_ignores.is_image_ignored("nginx:1.21.0"));
}
fn create_test_config() -> Config {
let mut registries = HashMap::new();
registries.insert(
"docker.io".to_string(),
RegistryConfig {
url: "https://registry-1.docker.io".to_string(),
auth_token: None,
},
);
Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 0 2 * * *".to_string(),
registries,
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec!["localhost".to_string()],
dry_run: true,
}
}
#[test]
fn test_env_var_substitution() {
// Test environment variable substitution in config
std::env::set_var("TEST_TOKEN", "test_value_123");
let test_yaml = r#"
registries:
"test.registry":
url: "https://test.registry"
auth_token: "${TEST_TOKEN}"
"#;
let expanded = Config::expand_env_vars(test_yaml);
assert!(expanded.contains("test_value_123"));
assert!(!expanded.contains("${TEST_TOKEN}"));
// Clean up
std::env::remove_var("TEST_TOKEN");
}
#[test]
fn test_config_loading_with_env_vars() {
// Set up test environment variable
std::env::set_var("GITHUB_TOKEN", "ghp_test_token_for_testing");
// Test that config loading works with our test config
if std::path::Path::new("config.test.yaml").exists() {
let config = Config::load(Path::new("config.test.yaml").to_path_buf()).unwrap();
if let Some(ghcr_config) = config.registries.get("ghcr.io") {
// Should have resolved the environment variable
assert_eq!(
ghcr_config.auth_token,
Some("ghp_test_token_for_testing".to_string())
);
}
}
// Clean up
std::env::remove_var("GITHUB_TOKEN");
}
#[tokio::test]
#[ignore = "Only run in CI with GITHUB_TOKEN set"]
async fn test_ghcr_authentication_e2e() {
let github_token = std::env::var("GITHUB_TOKEN").unwrap();
// This test validates that Docker Registry v2 authentication works with GHCR
let mut registries = HashMap::new();
registries.insert(
"docker.io".to_string(),
RegistryConfig {
url: "https://registry-1.docker.io".to_string(),
auth_token: None,
},
);
registries.insert(
"ghcr.io".to_string(),
RegistryConfig {
url: "https://ghcr.io".to_string(),
// Use the token we validated above
auth_token: Some(github_token),
},
);
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 0 2 * * *".to_string(),
registries,
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec!["localhost".to_string()],
dry_run: true,
};
let client = Client::new(config);
// Test with the actual image that was failing
let image_ref = ImageRef::parse("ghcr.io/schmelczer/vault-link:0.5.1").unwrap();
let result = client.get_available_versions(&image_ref).await;
match result {
Ok(versions) => {
assert!(!versions.is_empty(), "Should return at least one version");
// Verify that we can find the specific version 0.5.1
let has_target_version = versions.iter().any(|v| v.version.to_string() == "0.5.1");
assert!(
has_target_version,
"Should find target version 0.5.1 in available versions"
);
}
Err(e) => {
panic!("GHCR authentication test failed: {e}");
}
}
}

View file

@ -0,0 +1,509 @@
use docker_compose_updater::compose::updater::ComposeUpdater;
use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy};
use docker_compose_updater::registry::ImageRef;
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[test]
fn test_image_ref_comprehensive_parsing() {
// Test comprehensive image reference parsing scenarios
let test_cases = vec![
// (input, expected_registry, expected_namespace, expected_name, expected_tag)
// Standard Docker Hub images
("nginx", "docker.io", None, "nginx", "latest"),
("nginx:1.21", "docker.io", None, "nginx", "1.21"),
("nginx:latest", "docker.io", None, "nginx", "latest"),
// Docker Hub with namespace
(
"library/nginx:1.21",
"docker.io",
Some("library"),
"nginx",
"1.21",
),
(
"bitnami/nginx:1.21",
"docker.io",
Some("bitnami"),
"nginx",
"1.21",
),
// Custom registries
("quay.io/nginx:1.21", "quay.io", None, "nginx", "1.21"),
(
"gcr.io/project/nginx:1.21",
"gcr.io",
Some("project"),
"nginx",
"1.21",
),
(
"registry.k8s.io/pause:3.9",
"registry.k8s.io",
None,
"pause",
"3.9",
),
// GitHub Container Registry
(
"ghcr.io/user/app:v1.0.0",
"ghcr.io",
Some("user"),
"app",
"v1.0.0",
),
(
"ghcr.io/org/project:latest",
"ghcr.io",
Some("org"),
"project",
"latest",
),
// Local registries with ports
(
"localhost:5000/app:dev",
"localhost:5000",
None,
"app",
"dev",
),
(
"127.0.0.1:8080/ns/app:test",
"127.0.0.1:8080",
Some("ns"),
"app",
"test",
),
// Complex tags with versions and suffixes
(
"nginx:1.21.6-alpine",
"docker.io",
None,
"nginx",
"1.21.6-alpine",
),
(
"postgres:13.7-bullseye",
"docker.io",
None,
"postgres",
"13.7-bullseye",
),
(
"redis:7.0.0-alpine3.16",
"docker.io",
None,
"redis",
"7.0.0-alpine3.16",
),
// Enterprise/custom registries
(
"my-registry.company.com/team/app:v2.1.3",
"my-registry.company.com",
Some("team"),
"app",
"v2.1.3",
),
(
"harbor.example.org/project/nginx:stable",
"harbor.example.org",
Some("project"),
"nginx",
"stable",
),
];
for (input, expected_registry, expected_namespace, expected_name, expected_tag) in test_cases {
let result = ImageRef::parse(input);
assert!(result.is_ok(), "Failed to parse: {input}");
let image_ref = result.unwrap();
assert_eq!(
image_ref.registry, expected_registry,
"Registry mismatch for: {input}"
);
assert_eq!(
image_ref.namespace,
expected_namespace.map(String::from),
"Namespace mismatch for: {input}"
);
assert_eq!(image_ref.name, expected_name, "Name mismatch for: {input}");
assert_eq!(image_ref.tag, expected_tag, "Tag mismatch for: {input}");
// Test that the image ref can be converted back to string representation
let display_string = image_ref.to_string();
assert!(
!display_string.is_empty(),
"Display string should not be empty for: {input}"
);
// For Docker Hub images, the display should omit the registry
if expected_registry == "docker.io" {
assert!(
!display_string.starts_with("docker.io"),
"Docker Hub images should not show registry in display: {input}"
);
}
}
}
#[tokio::test]
async fn test_compose_file_parsing_edge_cases() {
let config = create_test_config();
let updater = ComposeUpdater::new(config);
// Test various compose file formats and edge cases
let test_cases = vec![
// Standard compose file
(
"standard_compose",
r#"
version: '3.8'
services:
web:
image: nginx:1.21.0
ports:
- "80:80"
db:
image: postgres:13.7
"#,
2, // Expected number of services
),
// Compose file with comments and whitespace
(
"with_comments",
r#"
# This is a comment
version: '3.8'
services:
# Web service comment
web:
image: nginx:1.21.0 # Inline comment
ports:
- "80:80"
# Database service
db:
image: postgres:13.7
# More comments
environment:
POSTGRES_DB: test
"#,
2,
),
// Compose file with complex image references
(
"complex_images",
r#"
version: '3.8'
services:
frontend:
image: ghcr.io/company/frontend:v2.1.0
backend:
image: my-registry.com:5000/team/backend:latest
cache:
image: redis:7.0.0-alpine3.16
database:
image: postgres:14.5-bullseye
"#,
4,
),
// Minimal compose file
(
"minimal",
r#"
version: '3'
services:
app:
image: hello-world
"#,
1,
),
// Compose file with extends and other features
(
"with_extends",
r#"
version: '3.8'
services:
base:
image: ubuntu:20.04
environment:
- ENV=production
app:
extends:
service: base
image: myapp:latest
ports:
- "3000:3000"
"#,
2,
),
];
for (test_name, compose_content, expected_services) in test_cases {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let result = updater.parse_compose_file(temp_file.path().to_str().unwrap());
assert!(result.is_ok(), "Failed to parse compose file: {test_name}");
let compose_file = result.unwrap();
assert_eq!(
compose_file.services.len(),
expected_services,
"Service count mismatch for: {test_name}"
);
// Verify that all services have valid image references
for service in &compose_file.services {
assert!(
!service.service_name.is_empty(),
"Service name should not be empty in: {test_name}"
);
assert!(
!service.image_ref.name.is_empty(),
"Image name should not be empty in: {test_name}"
);
assert!(
!service.image_ref.tag.is_empty(),
"Image tag should not be empty in: {test_name}"
);
assert!(
!service.image_ref.registry.is_empty(),
"Image registry should not be empty in: {test_name}"
);
}
}
}
#[tokio::test]
async fn test_compose_content_replacement() {
let config = create_test_config();
let updater = ComposeUpdater::new(config);
let original_content = r#"
version: '3.8'
services:
web:
image: nginx:1.20.0 # Current version
ports:
- "80:80"
db:
image: postgres:13.6
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(original_content.as_bytes()).unwrap();
let compose_file = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
// Test replacing nginx image
let nginx_service = compose_file
.services
.iter()
.find(|s| s.service_name == "web")
.unwrap();
let result =
updater.replace_image_in_content(&compose_file.content, nginx_service, "nginx:1.21.0");
assert!(result.is_ok(), "Should successfully replace image");
let new_content = result.unwrap();
assert!(
new_content.contains("nginx:1.21.0"),
"Should contain new image version"
);
assert!(
!new_content.contains("nginx:1.20.0"),
"Should not contain old image version"
);
assert!(
new_content.contains("# Current version"),
"Should preserve comments"
);
assert!(
new_content.contains("postgres:13.6"),
"Should preserve other services unchanged"
);
}
#[test]
fn test_compose_service_image_replacement_edge_cases() {
let config = create_test_config();
let updater = ComposeUpdater::new(config);
// Test cases with tricky image replacement scenarios
let test_cases = vec![
// Multiple occurrences of similar image names
(
r#"
version: '3.8'
services:
nginx-proxy:
image: nginx:1.20.0
nginx-cache:
image: nginx:1.21.0
web:
image: nginx:1.20.0
"#,
"nginx-proxy",
"nginx:1.20.0",
"nginx:1.22.0",
),
// Images with similar names but different registries
(
r#"
version: '3.8'
services:
public-nginx:
image: nginx:1.20.0
private-nginx:
image: my-registry.com/nginx:1.20.0
"#,
"public-nginx",
"nginx:1.20.0",
"nginx:1.21.0",
),
// Complex image with registry and namespace
(
r#"
version: '3.8'
services:
app:
image: ghcr.io/company/app:v1.0.0
environment:
- IMAGE_VERSION=v1.0.0
"#,
"app",
"ghcr.io/company/app:v1.0.0",
"ghcr.io/company/app:v1.1.0",
),
];
for (compose_content, service_name, old_image, new_image) in test_cases {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let compose_file = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
let target_service = compose_file
.services
.iter()
.find(|s| s.service_name == service_name)
.unwrap();
let result =
updater.replace_image_in_content(&compose_file.content, target_service, new_image);
assert!(
result.is_ok(),
"Should replace image for service: {service_name}"
);
let new_content = result.unwrap();
assert!(
new_content.contains(new_image),
"Should contain new image: {new_image}"
);
// Count occurrences to ensure only the right image was replaced
let old_count = compose_content.matches(old_image).count();
let new_old_count = new_content.matches(old_image).count();
assert_eq!(
new_old_count,
old_count - 1,
"Should replace exactly one occurrence for: {service_name}"
);
}
}
#[test]
fn test_image_ignore_patterns_comprehensive() {
// Test comprehensive ignore pattern scenarios
let test_cases = vec![
// Basic pattern matching
(vec!["localhost"], "localhost:5000/app:latest", true),
(vec!["localhost"], "nginx:latest", false),
// Multiple patterns
(
vec!["localhost", "127.0.0.1"],
"127.0.0.1:5000/app:latest",
true,
),
(
vec!["localhost", "127.0.0.1"],
"192.168.1.1:5000/app:latest",
false,
),
// Registry-based patterns
(vec!["ghcr.io"], "ghcr.io/user/app:latest", true),
(vec!["ghcr.io"], "docker.io/user/app:latest", false),
// Prefix patterns
(vec!["test-"], "test-app:latest", true),
(vec!["test-"], "app-test:latest", false),
(vec!["test-"], "testing:latest", false),
// Complex registry patterns
(
vec!["internal.company.com"],
"internal.company.com/team/app:v1.0",
true,
),
(
vec!["internal.company.com"],
"external.company.com/team/app:v1.0",
false,
),
// Partial matches
(vec!["redis"], "redis:latest", true),
(vec!["redis"], "valkey:latest", false),
(vec!["redis"], "my-redis:latest", true), // This should match as substring
];
for (ignore_patterns, image, should_be_ignored) in test_cases {
let config = Config {
ignore_images: ignore_patterns.iter().map(|s| s.to_string()).collect(),
..create_test_config()
};
let result = config.is_image_ignored(image);
assert_eq!(
result, should_be_ignored,
"Pattern {ignore_patterns:?} with image '{image}' should be ignored: {should_be_ignored}"
);
}
}
fn create_test_config() -> Config {
let mut registries = HashMap::new();
registries.insert(
"docker.io".to_string(),
RegistryConfig {
url: "https://registry-1.docker.io".to_string(),
auth_token: None,
},
);
registries.insert(
"ghcr.io".to_string(),
RegistryConfig {
url: "https://ghcr.io".to_string(),
auth_token: None,
},
);
Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 0 2 * * *".to_string(),
registries,
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
}
}

View file

@ -0,0 +1,346 @@
use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy};
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[test]
fn test_config_default_values() {
let config = Config::default();
// Verify all default values are reasonable
assert_eq!(config.schedule, "0 0 2 * * *");
assert_eq!(
config.update_strategy,
UpdateStrategy::LatestPatchOfPreviousMinor
);
assert!(!config.dry_run);
assert!(config.ignore_images.is_empty());
// compose_paths should be a valid Vec (length check is always true for usize)
let _paths_count = config.compose_paths.len();
// Should have default registries configured
assert!(config.registries.contains_key("docker.io"));
assert!(config.registries.contains_key("ghcr.io"));
let docker_registry = &config.registries["docker.io"];
assert_eq!(docker_registry.url, "https://registry-1.docker.io");
assert!(docker_registry.auth_token.is_none());
let ghcr_registry = &config.registries["ghcr.io"];
assert_eq!(ghcr_registry.url, "https://ghcr.io");
assert!(ghcr_registry.auth_token.is_none());
}
#[test]
fn test_config_from_different_file_formats() {
// Test loading from a properly formatted YAML file
let valid_yaml = r#"
compose_paths:
- "./docker-compose.yml"
- "./services/"
schedule: "0 0 3 * * *"
update_strategy: "Latest"
dry_run: true
ignore_images:
- "localhost"
- "127.0.0.1"
registries:
docker.io:
url: "https://registry-1.docker.io"
custom.registry.com:
url: "https://custom.registry.com"
auth_token: "secret-token"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(valid_yaml.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
assert!(result.is_ok(), "Should load valid YAML config");
let config = result.unwrap();
assert_eq!(config.compose_paths.len(), 2);
assert_eq!(config.schedule, "0 0 3 * * *");
assert_eq!(config.update_strategy, UpdateStrategy::Latest);
assert!(config.dry_run);
assert_eq!(config.ignore_images.len(), 2);
assert!(config.registries.contains_key("custom.registry.com"));
let custom_registry = &config.registries["custom.registry.com"];
assert_eq!(custom_registry.url, "https://custom.registry.com");
assert_eq!(custom_registry.auth_token, Some("secret-token".to_string()));
}
#[test]
fn test_config_with_invalid_yaml_syntax() {
let invalid_yamls = [
// Invalid YAML syntax
r#"
compose_paths:
- "./docker-compose.yml"
invalid_indent:
schedule: "0 0 2 * * *"
"#,
// Invalid field types
r#"
compose_paths: "not an array"
schedule: 123
dry_run: "not a boolean"
"#,
// Completely invalid YAML
r#"
{ invalid yaml syntax [
"#,
];
for (i, invalid_yaml) in invalid_yamls.iter().enumerate() {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(invalid_yaml.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
// Should either fail gracefully or fall back to defaults
if result.is_ok() {
let config = result.unwrap();
// If it succeeds, should have reasonable defaults
assert!(
!config.schedule.is_empty(),
"Schedule should not be empty for test case {i}"
);
}
// If it fails, that's also acceptable behavior for invalid YAML
}
}
#[test]
fn test_config_update_strategy_parsing() {
let strategies = vec![
("Latest", UpdateStrategy::Latest),
(
"LatestPatchOfPreviousMinor",
UpdateStrategy::LatestPatchOfPreviousMinor,
),
(
"LatestPatchOfPreviousMinor",
UpdateStrategy::LatestPatchOfPreviousMinor,
),
];
for (strategy_str, expected_strategy) in strategies {
let yaml_config = format!(
r#"
compose_paths: []
schedule: "0 0 2 * * *"
update_strategy: "{strategy_str}"
"#
);
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
assert!(
result.is_ok(),
"Should parse valid strategy: {strategy_str}"
);
let config = result.unwrap();
assert_eq!(config.update_strategy, expected_strategy);
}
}
#[test]
fn test_config_with_invalid_update_strategy() {
let yaml_config = r#"
compose_paths: []
schedule: "0 0 2 * * *"
update_strategy: "InvalidStrategy"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
assert!(result.is_err(), "Should not parse invalid update strategy");
}
#[test]
fn test_image_ignore_patterns_functionality() {
// Test various ignore patterns
let test_cases = vec![
// (ignore_pattern, image_to_test, should_be_ignored)
("localhost", "localhost:5000/app:latest", true),
("localhost", "nginx:latest", false),
("127.0.0.1", "127.0.0.1:5000/app:latest", true),
("127.0.0.1", "192.168.1.1:5000/app:latest", false),
("ghcr.io", "ghcr.io/user/app:latest", true),
("ghcr.io", "docker.io/nginx:latest", false),
("test-", "test-app:latest", true),
("test-", "app-test:latest", false),
];
for (ignore_pattern, image_to_test, should_be_ignored) in test_cases {
let config = Config {
ignore_images: vec![ignore_pattern.to_string()],
..Config::default()
};
let result = config.is_image_ignored(image_to_test);
assert_eq!(
result, should_be_ignored,
"Pattern '{ignore_pattern}' with image '{image_to_test}' should be ignored: {should_be_ignored}"
);
}
}
#[test]
fn test_multiple_ignore_patterns() {
let config = Config {
ignore_images: vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"test-".to_string(),
"ghcr.io".to_string(),
],
..Config::default()
};
let test_cases = vec![
("localhost:5000/app:latest", true),
("127.0.0.1:5000/app:latest", true),
("test-app:latest", true),
("ghcr.io/user/app:latest", true),
("docker.io/nginx:latest", false),
("registry.example.com/app:latest", false),
("app-test:latest", false),
];
for (image, should_be_ignored) in test_cases {
let result = config.is_image_ignored(image);
assert_eq!(
result, should_be_ignored,
"Image '{image}' should be ignored: {should_be_ignored}"
);
}
}
#[test]
fn test_registry_configuration_validation() {
// Test registry configs with various configurations
let mut registries = HashMap::new();
// Registry with just URL
registries.insert(
"simple.registry.com".to_string(),
RegistryConfig {
url: "https://simple.registry.com".to_string(),
auth_token: None,
},
);
// Registry with auth token
registries.insert(
"auth.registry.com".to_string(),
RegistryConfig {
url: "https://auth.registry.com".to_string(),
auth_token: Some("secret-token".to_string()),
},
);
let config = Config {
registries,
..Config::default()
};
// Verify registries are properly configured
assert!(config.registries.contains_key("simple.registry.com"));
assert!(config.registries.contains_key("auth.registry.com"));
let simple_registry = &config.registries["simple.registry.com"];
assert_eq!(simple_registry.url, "https://simple.registry.com");
assert!(simple_registry.auth_token.is_none());
let auth_registry = &config.registries["auth.registry.com"];
assert_eq!(auth_registry.url, "https://auth.registry.com");
assert_eq!(auth_registry.auth_token, Some("secret-token".to_string()));
}
#[test]
fn test_config_with_empty_fields() {
let yaml_config = r#"
compose_paths: []
schedule: ""
ignore_images: []
registries: {}
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
if result.is_ok() {
let config = result.unwrap();
assert!(config.compose_paths.is_empty());
assert!(config.ignore_images.is_empty());
// Empty schedule should either be rejected or have a default
assert!(!config.schedule.is_empty() || config.schedule.is_empty()); // Either is acceptable
}
// If loading fails for empty schedule, that's also acceptable
}
#[test]
fn test_config_compose_paths_handling() {
// Test various compose path configurations
let test_cases = vec![
// Single file path
vec!["./docker-compose.yml"],
// Multiple file paths
vec!["./docker-compose.yml", "./docker-compose.override.yml"],
// Directory paths
vec!["./services/", "./environments/"],
// Mixed paths
vec!["./docker-compose.yml", "./services/", "./override.yml"],
// Relative and absolute-looking paths
vec![
"docker-compose.yml",
"/app/config/compose.yml",
"../compose.yml",
],
];
for paths in test_cases {
let yaml_config = format!(
r#"
compose_paths:
{}
schedule: "0 0 2 * * *"
"#,
paths
.iter()
.map(|p| format!(" - \"{p}\""))
.collect::<Vec<_>>()
.join("\n")
);
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
assert!(result.is_ok(), "Should handle paths: {paths:?}");
let config = result.unwrap();
assert_eq!(config.compose_paths.len(), paths.len());
for (i, expected_path) in paths.iter().enumerate() {
assert_eq!(config.compose_paths[i], PathBuf::from(expected_path));
}
}
}

View file

@ -0,0 +1,279 @@
use docker_compose_updater::compose::updater::ComposeUpdater;
use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy};
use docker_compose_updater::registry::{Client as RegistryClient, ImageRef};
use docker_compose_updater::scheduler::Scheduler;
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use tempfile::{NamedTempFile, TempDir};
#[test]
fn test_scheduler_invalid_cron_expressions() {
// Test that scheduler now properly rejects invalid cron expressions instead of falling back
let invalid_cron_configs = [
"invalid cron",
"0 0 25 * * *", // Invalid hour
"60 0 2 * * *", // Invalid minute
"", // Empty string
"0 0 2 * *", // Missing field
];
for invalid_cron in invalid_cron_configs {
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: invalid_cron.to_string(),
registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
};
let result = Scheduler::new(config, None);
assert!(
result.is_err(),
"Expected error for invalid cron expression: '{invalid_cron}'"
);
}
}
#[test]
fn test_scheduler_valid_cron_expressions() {
// Test that scheduler accepts valid cron expressions
let valid_cron_configs = [
"0 0 2 * * *", // Default - 2 AM daily
"0 30 1 * * *", // 1:30 AM daily
"0 0 */6 * * *", // Every 6 hours
"0 0 2 * * MON", // Mondays at 2 AM
"0 15 14 1 * *", // 1st of month at 2:15 PM
];
for valid_cron in valid_cron_configs {
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: valid_cron.to_string(),
registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
};
let result = Scheduler::new(config, None);
assert!(
result.is_ok(),
"Expected success for valid cron expression: '{valid_cron}'"
);
}
}
#[test]
fn test_compose_invalid_file_paths() {
let config = create_test_config();
let updater = ComposeUpdater::new(config);
// Test with completely invalid path
let result = updater.parse_compose_file("/nonexistent/path/file.yml");
assert!(result.is_err(), "Should fail for nonexistent file");
// Test with directory instead of file
let temp_dir = TempDir::new().unwrap();
let result = updater.parse_compose_file(temp_dir.path().to_str().unwrap());
assert!(result.is_err(), "Should fail when path is a directory");
}
#[test]
fn test_compose_malformed_yaml() {
let config = create_test_config();
let updater = ComposeUpdater::new(config);
let malformed_yamls = [
// Invalid YAML syntax
r#"
version: '3.8'
services:
web:
image: nginx:1.21.0
invalid_indent
ports:
- "80:80"
"#,
// Missing required fields
r#"
version: '3.8'
# Missing services section
"#,
// Completely empty file
"",
// Invalid version format
r#"
version: invalid
services:
web:
image: nginx:1.21.0
"#,
];
for (i, malformed_yaml) in malformed_yamls.iter().enumerate() {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(malformed_yaml.as_bytes()).unwrap();
let result = updater.parse_compose_file(temp_file.path().to_str().unwrap());
// We expect these to either fail or return empty services (graceful degradation)
if result.is_ok() {
let compose_file = result.unwrap();
// If it parses successfully, it should at least not crash
assert!(
compose_file.services.is_empty() || !compose_file.services.is_empty(),
"Test case {i} should either fail or succeed gracefully"
);
}
// If it fails, that's also acceptable behavior for malformed YAML
}
}
#[test]
fn test_image_ref_parsing_edge_cases() {
// Test that image parsing doesn't crash on edge cases
let long_name = "a".repeat(300);
let edge_case_refs = [
"", // Empty string
":", // Just colon
"image:", // Missing tag
":tag", // Missing image
"registry.com/", // Missing image name
&long_name, // Extremely long name
];
for edge_case_ref in edge_case_refs {
let result = ImageRef::parse(edge_case_ref);
// The main goal is that parsing doesn't crash and returns a result
// Some might succeed (with defaults) or fail - both are acceptable
if result.is_ok() {
let _image_ref = result.unwrap();
// If it succeeds, that's fine - the parser is robust
// We don't make strict assertions about the content since
// the parser may use defaults for edge cases
}
// If they fail, that's also perfectly acceptable behavior
}
}
#[test]
fn test_registry_unknown_registry() {
let config = Config {
compose_paths: vec![],
schedule: "0 0 2 * * *".to_string(),
registries: HashMap::new(), // Empty registries
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
};
let registry_client = RegistryClient::new(config);
// Test with an unknown registry
let image_ref = ImageRef {
registry: "unknown.registry.com".to_string(),
namespace: Some("user".to_string()),
name: "app".to_string(),
tag: "1.0.0".to_string(),
};
// This should fail because the registry is not configured
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(registry_client.get_available_versions(&image_ref));
assert!(result.is_err(), "Should fail for unknown registry");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Unknown registry"),
"Error should mention unknown registry, got: {error_msg}"
);
}
#[tokio::test]
async fn test_compose_update_with_empty_paths() {
let config = Config {
compose_paths: vec![], // Empty paths
..create_test_config()
};
let updater = ComposeUpdater::new(config);
// Should handle empty paths gracefully
let result = updater.update_all_compose_files().await;
assert!(result.is_ok(), "Should handle empty paths gracefully");
assert!(
result.unwrap().is_empty(),
"Should return empty list for empty paths"
);
}
#[test]
fn test_config_edge_cases() {
// Test config with minimal valid data
let minimal_config = Config {
compose_paths: vec![], // Empty paths
schedule: "0 0 2 * * *".to_string(),
registries: HashMap::new(), // No registries
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
};
// Should be able to create scheduler with minimal config
let scheduler_result = Scheduler::new(minimal_config.clone(), None);
assert!(
scheduler_result.is_ok(),
"Should accept minimal valid config"
);
// Should be able to create compose updater with minimal config
let _updater = ComposeUpdater::new(minimal_config.clone());
// This should not panic
assert!(!minimal_config.is_image_ignored("any-image:tag"));
}
#[test]
fn test_version_selection_with_mismatched_prefixes_suffixes() {
use docker_compose_updater::strategy::create_selector;
use docker_compose_updater::version::VersionInfo;
let config = create_test_config();
let selector = create_selector(&config.update_strategy);
let current_prefix = Some("v".to_string());
let current_suffix = Some("-alpine".to_string());
let available = [
VersionInfo::from_tag("1.3.0").unwrap(),
VersionInfo::from_tag("v1.3.0-ubuntu").unwrap(),
VersionInfo::from_tag("release-1.1.5-alpine").unwrap(),
];
let target = selector.select_target_version(&available, current_prefix, current_suffix);
assert!(
target.is_none(),
"Should not find target when prefix/suffix don't match"
);
}
fn create_test_config() -> Config {
let mut registries = HashMap::new();
registries.insert(
"docker.io".to_string(),
RegistryConfig {
url: "https://registry-1.docker.io".to_string(),
auth_token: None,
},
);
Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 0 2 * * *".to_string(),
registries,
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
}
}

View file

@ -0,0 +1,44 @@
use docker_compose_updater::compose::updater::ComposeUpdater;
use docker_compose_updater::config::{Config, UpdateStrategy};
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[tokio::test]
async fn test_compose_update_with_suffix_preservation() {
let compose_content = r#"
version: '3.8'
services:
web:
image: nginx:1.21.0-alpine
ports:
- "80:80"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let config = Config {
compose_paths: vec![PathBuf::from(temp_file.path().to_str().unwrap())],
schedule: "0 0 2 * * *".to_string(),
registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
};
let updater = ComposeUpdater::new(config);
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
assert_eq!(result.services[0].image_ref.tag, "1.21.0-alpine");
let new_content = updater
.replace_image_in_content(&result.content, &result.services[0], "nginx:1.22.0-alpine")
.unwrap();
assert!(new_content.contains("nginx:1.22.0-alpine"));
assert!(!new_content.contains("nginx:1.21.0-alpine"));
}