Initial
This commit is contained in:
commit
3f60b72c3b
48 changed files with 6599 additions and 0 deletions
41
.dockerignore
Normal file
41
.dockerignore
Normal 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
126
.github/workflows/docker-publish.yml
vendored
Normal 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
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
83
CLAUDE.md
Normal file
83
CLAUDE.md
Normal 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
2059
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
34
Cargo.toml
Normal file
34
Cargo.toml
Normal 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
46
Dockerfile
Normal 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
377
README.md
Normal 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
4
compose/backup.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: active
|
||||||
|
services:
|
||||||
|
backup:
|
||||||
|
image: ghcr.io/schmelczer/backup-container:v0.0.3
|
||||||
11
compose/calibre.yml
Normal file
11
compose/calibre.yml
Normal 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
10
compose/cloud.yml
Normal 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
4
compose/dns.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: active
|
||||||
|
services:
|
||||||
|
dns-server:
|
||||||
|
image: technitium/dns-server:13.6.0
|
||||||
39
compose/frontdoor.yml
Normal file
39
compose/frontdoor.yml
Normal 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
20
compose/github-runner.yml
Normal 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
|
||||||
4
compose/homeassistant.yml
Normal file
4
compose/homeassistant.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: active
|
||||||
|
services:
|
||||||
|
homeassistant:
|
||||||
|
image: homeassistant/home-assistant:2025.7.2
|
||||||
17
compose/immich.yml
Normal file
17
compose/immich.yml
Normal 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
22
compose/media.yml
Normal 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
4
compose/minecraft.yml
Normal 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
4
compose/obsidian.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: active
|
||||||
|
services:
|
||||||
|
obsidian:
|
||||||
|
image: ghcr.io/schmelczer/vault-link:0.5.1
|
||||||
19
compose/paperless.yml
Normal file
19
compose/paperless.yml
Normal 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
7
compose/pdf.yml
Normal 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
7
compose/pinyin-anki.yml
Normal 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
16
compose/plausible.yml
Normal 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
54
compose/projects.yml
Normal 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
4
compose/smtp.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: active
|
||||||
|
services:
|
||||||
|
smtp:
|
||||||
|
image: bytemark/smtp:latest
|
||||||
33
compose/ssh.yml
Normal file
33
compose/ssh.yml
Normal 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
18
compose/stack.yml
Normal 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
4
compose/vaultwarden.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: active
|
||||||
|
services:
|
||||||
|
vaultwarden:
|
||||||
|
image: vaultwarden/server:1.34.1
|
||||||
4
compose/wireguard.yml
Normal file
4
compose/wireguard.yml
Normal 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
46
config.example.yaml
Normal 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
22
demo-config.yaml
Normal 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
44
docker-compose.yml
Normal 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
2
src/compose.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod parser;
|
||||||
|
pub mod updater;
|
||||||
186
src/compose/parser.rs
Normal file
186
src/compose/parser.rs
Normal 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
293
src/compose/updater.rs
Normal 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
105
src/config.rs
Normal 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: ®ex::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) = ®istry_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
114
src/health.rs
Normal 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
7
src/lib.rs
Normal 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
77
src/main.rs
Normal 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
605
src/registry.rs
Normal 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) = ®istry_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
164
src/scheduler.rs
Normal 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
238
src/strategy.rs
Normal 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
187
src/version.rs
Normal 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
259
tests/integration_tests.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
509
tests/test_compose_operations.rs
Normal file
509
tests/test_compose_operations.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
346
tests/test_config_validation.rs
Normal file
346
tests/test_config_validation.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
tests/test_error_handling.rs
Normal file
279
tests/test_error_handling.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
44
tests/test_suffix_handling.rs
Normal file
44
tests/test_suffix_handling.rs
Normal 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"));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue