diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d1511864..d7dee964 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,147 +1,159 @@ -name: Build +name: Release Docker Build on: - release: - types: [published] + push: + tags: + - '*' jobs: - test: - name: Docker Test on ubuntu-latest - runs-on: ubuntu-latest - services: - registry: - image: registry:2 - ports: - - 5000:5000 + build: + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-22.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} steps: - - name: Checkout Repository + - name: Checkout code uses: actions/checkout@v6 - with: - fetch-depth: 1 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - with: - platforms: amd64 + + - name: Extract version + id: version + run: | + # Extract version from tag (e.g., v1.2.3 -> 1.2.3) + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + # Extract major and minor versions for additional tags + IFS='.' read -r -a version_parts <<< "$VERSION" + MAJOR="${version_parts[0]}" + MINOR="${version_parts[1]}" + echo "major=$MAJOR" >> $GITHUB_OUTPUT + echo "minor=$MINOR" >> $GITHUB_OUTPUT + echo "Major: $MAJOR, Minor: $MINOR" + + - name: Extract tag name + id: tag + run: | + TAG_NAME=${GITHUB_REF#refs/tags/} + echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT + echo "Tag: $TAG_NAME" + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 with: - version: latest - driver-opts: network=host - - name: Update Version String + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare Docker tags + id: tags run: | - sed -i -r 's/VERSION = "custom"/VERSION = "0.0+test"/' pyouroboros/__init__.py - echo $?\ - - name: Build Docker + echo "tags=ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}-${{ matrix.arch }}" >> $GITHUB_OUTPUT + + - name: Build and push release Docker images uses: docker/build-push-action@v6 with: + push: true context: . file: ./Dockerfile - platforms: linux/amd64 - push: true - cache-from: type=gha,scope=main - cache-to: type=gha,mode=max,scope=main - tags: localhost:5000/tester/ouroboros:test - - name: Test with Docker - run: | - sudo mkdir -p /app/pyouroboros/hooks - docker run --rm --name ouroboros -v /var/run/docker.sock:/var/run/docker.sock localhost:5000/tester/ouroboros:test --run-once --dry-run --log-level debug - build: - name: Docker Build on ubuntu-latest + platforms: ${{ matrix.platform }} + cache-from: type=gha,scope=linux-${{ matrix.arch }} + cache-to: type=gha,scope=linux-${{ matrix.arch }},mode=max + #cache-from: type=registry,ref=ghcr.io/${{ github.repository }}/buildcache:linux-${{ matrix.arch }} + #cache-to: type=registry,ref=ghcr.io/${{ github.repository }}/buildcache:linux-${{ matrix.arch }},mode=max + + sbom: true + provenance: true + build-args: | + VERSION=${{ steps.version.outputs.version }} + VCS_REF=${{ github.sha }} + tags: ${{ steps.tags.outputs.tags }} + + merge: runs-on: ubuntu-latest - needs: test + needs: build steps: - - name: Check Credentials - id: check_credentials - env: - DOCKER_USER: ${{ secrets.DOCKER_USER }} - DOCKER_CLITOKEN: ${{ secrets.DOCKER_CLITOKEN }} - run: | - if [ "${DOCKER_USER}" == "" ]; then - echo "Missing User" - echo "missingsecrets=yes" >> $GITHUB_OUTPUT - elif [ "${DOCKER_CLITOKEN}" == "" ]; then - echo "Missing Cli Token" - echo "missingsecrets=yes" >> $GITHUB_OUTPUT - else - echo "All secrets present" - echo "missingsecrets=no" >> $GITHUB_OUTPUT - fi - - name: Checkout Repository - if: contains(steps.check_credentials.outputs.missingsecrets, 'no') + - name: Checkout code uses: actions/checkout@v6 - with: - fetch-depth: 1 - - name: Get Revision Variables - id: build_env + + - name: Extract version + id: version run: | - echo ${GITHUB_REF:10} - echo "branch=${GITHUB_REF:10}" >> $GITHUB_OUTPUT - - name: Set up QEMU - if: contains(steps.check_credentials.outputs.missingsecrets, 'no') - uses: docker/setup-qemu-action@v3 - with: - platforms: amd64,arm64,arm - - name: Set up Docker Buildx - if: contains(steps.check_credentials.outputs.missingsecrets, 'no') - uses: docker/setup-buildx-action@v3 - with: - version: latest - - name: Login to DockerHub Registry - if: contains(steps.check_credentials.outputs.missingsecrets, 'no') - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USER }} - password: ${{ secrets.DOCKER_CLITOKEN }} - logout: true + # Extract version from tag (e.g., v1.2.3 -> 1.2.3) + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + # Extract major and minor versions for additional tags + IFS='.' read -r -a version_parts <<< "$VERSION" + MAJOR="${version_parts[0]}" + MINOR="${version_parts[1]}" + echo "major=$MAJOR" >> $GITHUB_OUTPUT + echo "minor=$MINOR" >> $GITHUB_OUTPUT + echo "Major: $MAJOR, Minor: $MINOR" + + - name: Extract tag name + id: tag + run: | + TAG_NAME=${GITHUB_REF#refs/tags/} + echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT + echo "Tag: $TAG_NAME" + - name: Login to GitHub Container Registry - if: contains(steps.check_credentials.outputs.missingsecrets, 'no') uses: docker/login-action@v3 with: registry: ghcr.io - username: ${{ github.repository_owner }} + username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - logout: true - - name: Update Version String - if: contains(steps.check_credentials.outputs.missingsecrets, 'no') - env: - OUROBOROS_VERSION: ${{ steps.build_env.outputs.branch }} + + - name: Create and push multi-arch manifests run: | - sed -i -r 's/VERSION = "custom"/VERSION = "'$OUROBOROS_VERSION'"/' pyouroboros/__init__.py - echo $?\ - - name: Build and Push Docker - if: contains(steps.check_credentials.outputs.missingsecrets, 'no') - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/arm/v7 - push: true - cache-from: type=gha,scope=main - cache-to: type=gha,mode=max,scope=main - tags: | - ${{ secrets.DOCKER_USER }}/ouroboros:${{ steps.build_env.outputs.branch }} - ${{ secrets.DOCKER_USER }}/ouroboros:latest - ghcr.io/${{ github.repository_owner }}/ouroboros:${{ steps.build_env.outputs.branch }} - ghcr.io/${{ github.repository_owner }}/ouroboros:latest - cleanup: - name: Cleanup Cache - runs-on: ubuntu-latest - needs: [test, build] - steps: - - name: Cleanup Cache + # GHCR manifests + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }} \ + ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}-amd64 \ + ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}-arm64 + + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}/ouroboros:latest \ + ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}-amd64 \ + ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}-arm64 + + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.major }}.${{ steps.version.outputs.minor }} \ + ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}-amd64 \ + ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}-arm64 + + docker buildx imagetools create \ + -t ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.major }} \ + ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}-amd64 \ + ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}-arm64 + + - name: Create GitHub release run: | - gh extension install actions/gh-actions-cache - REPO="${{ github.repository }}" - BRANCH="${{ github.ref }}" - echo "Fetching list of cache keys" - cacheKeys=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) - ## Setting this to not fail the workflow while deleting cache keys. - set +e - echo "Deleting caches..." - for cacheKey in $cacheKeys - do - gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm - done - echo "Done" + NOTES="Release ${{ steps.tag.outputs.tag }} + + Built from tag: ${{ steps.tag.outputs.tag }} + + Docker Images: + - \`ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.version }}\` + - \`ghcr.io/${{ github.repository }}/ouroboros:latest\` + - \`ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.major }}.${{ steps.version.outputs.minor }}\` + - \`ghcr.io/${{ github.repository }}/ouroboros:${{ steps.version.outputs.major }}\`" + gh release create ${{ steps.tag.outputs.tag }} \ + --title "Release ${{ steps.tag.outputs.tag }}" \ + --notes "$NOTES" \ + --latest env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e677c7b9..04de955c 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -1,13 +1,18 @@ name: Test Build +# Workflow disabled on: - push: - pull_request: workflow_dispatch: + inputs: + enabled: + description: 'Workflow is disabled' + required: false + default: 'false' jobs: test: name: Docker Test on ubuntu-latest + if: false # Workflow disabled runs-on: ubuntu-latest services: registry: @@ -24,7 +29,7 @@ jobs: with: platforms: amd64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 with: version: latest driver-opts: network=host diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3d601a7d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,204 @@ +# Ouroboros - Agent Documentation + +## What It Does + +Ouroboros is an automated Docker container update agent that monitors running containers and automatically updates them to the latest available images from their registries. It preserves all container configuration (volumes, networks, environment variables, restart policies, etc.) during updates. + +## Core Functionality + +### Primary Purpose +- **Automated Updates**: Monitors Docker containers and updates them when new images are available +- **Zero-Downtime Philosophy**: Recreates containers with identical configuration to maintain service continuity +- **Self-Update Capability**: Can update itself without manual intervention +- **Multi-Mode Support**: Works with both standard Docker containers and Docker Swarm services + +## Architecture Overview + +### Main Components + +1. **`ouroboros.py`** - Entry point and scheduler + - Parses CLI arguments and environment variables + - Sets up APScheduler for interval or cron-based execution + - Manages Docker socket connections + - Coordinates update cycles + +2. **`dockerclient.py`** - Docker API interaction + - `Docker` class: Manages Docker client connections (supports TLS) + - `Container` class: Handles standard container updates + - `Service` class: Handles Docker Swarm service updates + - Image pulling, container recreation, network management + +3. **`config.py`** - Configuration management + - Merges environment variables and CLI arguments + - Validates configuration + - Filters sensitive data from logs + +4. **`notifiers.py`** - Notification system + - Uses Apprise library for multi-platform notifications + - Supports Discord, Slack, Email, Telegram, and many more + - Sends startup, update, and monitor-only notifications + +5. **`dataexporters.py`** - Metrics export + - Prometheus exporter (HTTP endpoint) + - InfluxDB client + - Tracks container update counts and monitoring stats + +6. **`helpers.py`** - Utility functions + - Container property preservation + - Hook system execution + - Image digest handling + +## How It Works + +### Update Cycle Flow + +1. **Initialization** + - Connects to Docker socket(s) (Unix or TCP, with optional TLS) + - Loads configuration from environment variables and CLI args + - Sets up scheduler (interval-based or cron) + - Initializes notification and metrics systems + +2. **Container Discovery** + - Lists running containers (excludes itself unless self-update enabled) + - Filters based on: + - `--monitor` list (specific containers) + - `--ignore` list (excluded containers) + - `com.ouroboros.enable` label (if label monitoring enabled) + - `--labels-only` mode (only containers with labels) + +3. **Update Detection** + - For each monitored container: + - Gets current image tag and digest + - Pulls latest image from registry (with auth if configured) + - Compares image IDs/digests + - If different, marks container for update + +4. **Dependency Handling** + - Checks for `com.ouroboros.depends_on` label (soft dependency) + - Checks for `com.ouroboros.hard_depends_on` label (hard dependency) + - Stops dependent containers before updating parent + - Recreates hard dependencies after update + +5. **Container Recreation** + - Stops old container (with custom signal if `com.ouroboros.stop_signal` label set) + - Removes old container + - Creates new container with: + - Same name, hostname, user, working directory + - Same volumes, ports, environment variables + - Same entrypoint, command, labels + - Same restart policy, network configuration + - Same healthcheck configuration + - Reconnects to all networks from old container + - Starts new container + +6. **Cleanup** + - Optionally removes old images (`--cleanup`) + - Optionally prunes unused volumes (`--cleanup-unused-volumes`) + +7. **Notifications & Metrics** + - Sends update notifications via Apprise + - Updates Prometheus/InfluxDB metrics + - Executes hooks at various stages + +### Scheduling + +- **Interval Mode**: Checks every N seconds (default: 300) +- **Cron Mode**: Uses cron syntax for scheduled checks +- **Run Once**: Single execution mode (useful with `--dry-run`) + +### Self-Update Mechanism + +When `--self-update` is enabled: +- Ouroboros can update itself +- Creates new container named `ouroboros-updated` +- Starts new instance +- Old instance stops and removes itself after 30 seconds +- Preserves metrics counters across updates + +### Docker Swarm Mode + +When `--swarm` is enabled: +- Monitors Docker Swarm services instead of containers +- Uses service labels (`com.ouroboros.enable`) +- Updates services via `service.update()` with new image digest +- Compares SHA256 digests instead of image IDs + +## Key Features + +### Filtering & Selection +- Monitor specific containers: `--monitor container1 container2` +- Ignore containers: `--ignore container1 container2` +- Label-based monitoring: `--label-enable` + `com.ouroboros.enable=true` +- Labels-only mode: `--labels-only` (strict compliance) + +### Update Behavior +- **Tag Preservation**: Updates to same tag by default +- **Latest Only**: `--latest-only` forces updates to `:latest` tag +- **Dry Run**: `--dry-run` shows what would be updated without making changes +- **Monitor Only**: `--monitor-only` sends notifications but doesn't update + +### Notifications +- Multi-platform via Apprise (Discord, Slack, Email, Telegram, etc.) +- Startup notifications (can be disabled) +- Update notifications with container details +- Monitor-only notifications for available updates + +### Metrics +- **Prometheus**: HTTP endpoint on configurable address/port +- **InfluxDB**: Writes update counts and monitoring stats +- Tracks: containers monitored, total updated, per-container counts + +### Hooks System +- Executes Python scripts from `hooks/{hookname}/` directories +- Available hooks: + - `updates_enumerated`: Before processing updates + - `before_update`: Before container recreation + - `after_update`: After container recreation + - `before_image_cleanup`: Before removing old images + - `before_stop_depends_container`: Before stopping dependencies + - `before_start_depends_container`: Before starting dependencies + - `before_recreate_hard_depends_container`: Before recreating hard dependencies + - `after_recreate_hard_depends_container`: After recreating hard dependencies + - `before_self_update`: Before self-update + - `after_self_update`: After self-update + - `before_self_cleanup`: Before cleaning up old self + - `after_self_cleanup`: After cleaning up old self + - `dry_run_update`: During dry-run mode + - `notify_update`: During monitor-only mode + +### Security +- Docker TLS support with certificate verification +- Private registry authentication +- Sensitive data filtering in logs +- Network isolation support + +## Configuration Methods + +1. **Environment Variables**: All options can be set via `UPPERCASE` env vars +2. **CLI Arguments**: Command-line flags override defaults +3. **Precedence**: CLI args > Environment vars > Defaults + +## Dependencies + +- `docker` (>=4.3.1): Docker API client +- `apscheduler` (>=3.6.3): Job scheduling +- `apprise` (>=0.8.9): Multi-platform notifications +- `prometheus_client` (>=0.8.0): Metrics export +- `influxdb` (>=5.3.1): InfluxDB client +- `Babel` (>=2.9.1): Internationalization +- `pytz`: Timezone support + +## Deployment + +- **Docker**: Runs as a container with Docker socket mounted +- **Multi-arch**: Supports amd64, arm64, arm/v7 +- **Standalone**: Can be installed via pip and run directly + +## Use Cases + +1. **Automated CI/CD**: Push new images, Ouroboros deploys automatically +2. **Multi-host Management**: Monitor containers across multiple Docker hosts +3. **Production Updates**: Scheduled updates during maintenance windows (cron) +4. **Development**: Quick iteration with short intervals +5. **Compliance**: Label-based monitoring for strict update policies + diff --git a/CHANGELOG.md b/CHANGELOG.md index 25576e35..0f7d34ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Change Log +## [v1.10.0](https://github.com/styliteag/ouroboros/tree/v1.10.0) (2026-01-07) + +**Note:** This is a fork of [gmt2001/ouroboros](https://github.com/gmt2001/ouroboros) + +**Implemented enhancements:** + +- Added `--single` flag to update only one service/container at a time, allowing for controlled rollouts and reduced system load +- Added `--single-wait` option to specify wait time in seconds after updating a service/container when `--single` is enabled, ensuring services have time to stabilize before the next update + +**Fixed bugs:** + +- Fix Docker build: convert 'custom' version to valid PEP 440 format for local builds + +**Other changes:** + +- Update maintainer and repository information + ## [v1.9.0](https://github.com/gmt2001/ouroboros/tree/v1.9.0) (2021-09-20) [Full Changelog](https://github.com/gmt2001/ouroboros/compare/1.9.0...v1.8.0) diff --git a/README.md b/README.md index 6b04bad0..19c834cf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -Ouroboros Logo +Ouroboros Logo Automatically update your running Docker containers to the latest available image. @@ -29,7 +29,7 @@ Ouroboros is deployed via docker image like so: ```bash docker run -d --name ouroboros \ -v /var/run/docker.sock:/var/run/docker.sock \ - ghcr.io/gmt2001/ouroboros + ghcr.io/styliteag/ouroboros ``` > This image is compatible with amd64, arm64, and arm/v7 CPU architectures diff --git a/docker-compose.yml b/docker-compose.yml index 506db57d..70b895ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: ouroboros: container_name: ouroboros hostname: ouroboros - image: ghcr.io/gmt2001/ouroboros + image: ghcr.io/styliteag/ouroboros environment: - CLEANUP=true - INTERVAL=300 diff --git a/pyouroboros/__init__.py b/pyouroboros/__init__.py index 838f29d2..3a7c8c4c 100644 --- a/pyouroboros/__init__.py +++ b/pyouroboros/__init__.py @@ -1,2 +1,2 @@ -VERSION = "custom" +VERSION = "1.11.6" BRANCH = "main" diff --git a/pyouroboros/config.py b/pyouroboros/config.py index f19e611d..7eceac03 100644 --- a/pyouroboros/config.py +++ b/pyouroboros/config.py @@ -9,7 +9,7 @@ class Config(object): 'INFLUX_URL', 'INFLUX_PORT', 'INFLUX_USERNAME', 'INFLUX_PASSWORD', 'INFLUX_DATABASE', 'INFLUX_SSL', 'INFLUX_VERIFY_SSL', 'DATA_EXPORT', 'SELF_UPDATE', 'LABEL_ENABLE', 'DOCKER_TLS', 'LABELS_ONLY', 'DRY_RUN', 'MONITOR_ONLY', 'HOSTNAME', 'DOCKER_TLS_VERIFY', 'SWARM', 'SKIP_STARTUP_NOTIFICATIONS', 'LANGUAGE', - 'TZ', 'CLEANUP_UNUSED_VOLUMES', 'DOCKER_TIMEOUT', 'LATEST_ONLY', 'SAVE_COUNTERS'] + 'TZ', 'CLEANUP_UNUSED_VOLUMES', 'DOCKER_TIMEOUT', 'LATEST_ONLY', 'SAVE_COUNTERS', 'SINGLE', 'SINGLE_WAIT'] hostname = environ.get('HOSTNAME') interval = 300 @@ -56,6 +56,8 @@ class Config(object): notifiers = [] skip_startup_notifications = False + single = False + single_wait = 0 def __init__(self, environment_vars, cli_args): self.cli_args = cli_args @@ -95,7 +97,7 @@ def parse(self): if isinstance(env_opt, str): # Clean out quotes, both single/double and whitespace env_opt = env_opt.strip("'").strip('"').strip(' ') - if option in ['INTERVAL', 'GRACE', 'PROMETHEUS_PORT', 'INFLUX_PORT', 'DOCKER_TIMEOUT']: + if option in ['INTERVAL', 'GRACE', 'PROMETHEUS_PORT', 'INFLUX_PORT', 'DOCKER_TIMEOUT', 'SINGLE_WAIT']: try: opt = int(env_opt) setattr(self, option.lower(), opt) @@ -103,7 +105,7 @@ def parse(self): print(e) elif option in ['CLEANUP', 'RUN_ONCE', 'INFLUX_SSL', 'INFLUX_VERIFY_SSL', 'DRY_RUN', 'MONITOR_ONLY', 'SWARM', 'SELF_UPDATE', 'LABEL_ENABLE', 'DOCKER_TLS', 'LABELS_ONLY', 'DOCKER_TLS_VERIFY', - 'SKIP_STARTUP_NOTIFICATIONS', 'CLEANUP_UNUSED_VOLUMES', 'LATEST_ONLY']: + 'SKIP_STARTUP_NOTIFICATIONS', 'CLEANUP_UNUSED_VOLUMES', 'LATEST_ONLY', 'SINGLE']: if env_opt.lower() in ['true', 'yes']: setattr(self, option.lower(), True) elif env_opt.lower() in ['false', 'no']: diff --git a/pyouroboros/dockerclient.py b/pyouroboros/dockerclient.py index bf34bb2e..88d5964a 100644 --- a/pyouroboros/dockerclient.py +++ b/pyouroboros/dockerclient.py @@ -269,6 +269,10 @@ def socket_check(self): except ConnectionError: continue + if latest_image is None: + self.logger.error('Failed to pull image %s for container %s. Skipping', current_tag, container.name) + continue + try: if current_image.id != latest_image.id: updateable.append((container, current_image, latest_image)) @@ -306,6 +310,7 @@ def socket_check(self): def update(self): updated_count = 0 + actually_updated = [] try: updateable, depends_on_containers, hard_depends_on_containers = self.socket_check() mylocals = {} @@ -395,6 +400,18 @@ def update(self): self.data_manager.total_updated[self.socket] += 1 self.data_manager.add(label=container.name, socket=self.socket) self.data_manager.add(label='all', socket=self.socket) + actually_updated.append((container.name, current_image, latest_image)) + + if self.config.single: + if self.config.single_wait > 0: + self.logger.info('Waiting %d seconds before next update (single mode)', self.config.single_wait) + sleep(self.config.single_wait) + # Send notifications for this update before processing next (skip if already sent for self-update) + if updated_count > 0 and container.name not in ['ouroboros', 'ouroboros-updated']: + notification_tuples = actually_updated if actually_updated else updateable + self.notification_manager.send(container_tuples=notification_tuples, socket=self.socket, kind='update') + # Continue with next container in current scan cycle + continue for container in depends_on_containers: # Reload container to ensure it isn't referencing the old image @@ -417,7 +434,8 @@ def update(self): run_hook('after_recreate_hard_depends_container', None, mylocals) if updated_count > 0: - self.notification_manager.send(container_tuples=updateable, socket=self.socket, kind='update') + notification_tuples = actually_updated if actually_updated else updateable + self.notification_manager.send(container_tuples=notification_tuples, socket=self.socket, kind='update') def update_self(self, count=None, old_container=None, me_list=None, new_image=None): if count == 2: @@ -526,6 +544,10 @@ def update(self): except ConnectionError: continue + if latest_image is None: + self.logger.error('Failed to pull image %s. Skipping', tag) + continue + latest_image_sha256 = get_digest(latest_image) self.logger.debug('Latest sha256 for %s is %s', tag, latest_image_sha256) @@ -557,12 +579,37 @@ def update(self): socket=self.socket, kind='update', mode='service') self.logger.info('%s will be updated', service.name) - service.update(image=f"{tag}@sha256:{latest_image_sha256}") + try: + # Reload service to get latest version before updating + service.reload() + service.update(image=f"{tag}@sha256:{latest_image_sha256}") + except APIError as e: + if 'update out of sequence' in str(e): + self.logger.warning('Service %s was updated by another process. Skipping this update cycle.', service.name) + continue + else: + self.logger.error('Failed to update service %s: %s', service.name, e) + continue self.data_manager.total_updated[self.socket] += 1 self.data_manager.add(label=service.name, socket=self.socket) self.data_manager.add(label='all', socket=self.socket) + if self.config.single: + if self.config.single_wait > 0: + self.logger.info('Waiting %d seconds before next update (single mode)', self.config.single_wait) + sleep(self.config.single_wait) + # Send notifications for this update before processing next (skip if already sent for self-update) + if updated_service_tuples and not ('ouroboros' in service.name and self.config.self_update): + self.notification_manager.send( + container_tuples=updated_service_tuples, + socket=self.socket, + kind='update', + mode='service' + ) + # Continue with next service in current scan cycle + continue + if updated_service_tuples: self.notification_manager.send( container_tuples=updated_service_tuples, diff --git a/pyouroboros/helpers.py b/pyouroboros/helpers.py index 01be9d95..59d19cab 100644 --- a/pyouroboros/helpers.py +++ b/pyouroboros/helpers.py @@ -119,6 +119,8 @@ def get_digest(image) -> str: """ Utility to locate the digest of an image and return it """ + if image is None: + raise ValueError("Cannot get digest from None image") digest = image.attrs.get( "Descriptor", {} ).get("digest") or image.attrs.get( diff --git a/pyouroboros/notifiers.py b/pyouroboros/notifiers.py index b0683988..bacc0d44 100644 --- a/pyouroboros/notifiers.py +++ b/pyouroboros/notifiers.py @@ -25,14 +25,14 @@ def __init__(self, config, data_manager): def build_apprise(self): asset = apprise.AppriseAsset( - image_url_mask='https://raw.githubusercontent.com/gmt2001/ouroboros/main/assets/ouroboros_logo_icon_72.png', + image_url_mask='https://raw.githubusercontent.com/styliteag/ouroboros/main/assets/ouroboros_logo_icon_72.png', default_extension='.png' ) asset.app_id = "Ouroboros" asset.app_desc = "Ouroboros" - asset.app_url = "https://github.com/gmt2001/ouroboros" + asset.app_url = "https://github.com/styliteag/ouroboros" asset.html_notify_map['info'] = '#5F87C6' - asset.image_url_logo = 'https://raw.githubusercontent.com/gmt2001/ouroboros/main/assets/ouroboros_logo_icon_256.png' + asset.image_url_logo = 'https://raw.githubusercontent.com/styliteag/ouroboros/main/assets/ouroboros_logo_icon_256.png' apprise_obj = apprise.Apprise(asset=asset) diff --git a/pyouroboros/ouroboros.py b/pyouroboros/ouroboros.py index 1a5172c7..f1022e78 100644 --- a/pyouroboros/ouroboros.py +++ b/pyouroboros/ouroboros.py @@ -161,6 +161,13 @@ def main(): dest='SKIP_STARTUP_NOTIFICATIONS', action='store_true', help='Do not send ouroboros notifications when starting') + docker_group.add_argument('--single', default=Config.single, dest='SINGLE', action='store_true', + help='Only update one service/container at a time') + + docker_group.add_argument('--single-wait', type=int, default=Config.single_wait, dest='SINGLE_WAIT', + help='Wait time in seconds after updating a service/container when --single is enabled\n' + 'DEFAULT: 0') + _ = None args = parser.parse_args() diff --git a/release.sh b/release.sh new file mode 100755 index 00000000..6655d7ae --- /dev/null +++ b/release.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# Release script for Ouroboros +# Usage: ./release.sh +# Example: ./release.sh 1.10.1 + +set -e + +VERSION=$1 +if [ -z "$VERSION" ]; then + echo "Usage: $0 " + echo "Example: $0 1.10.1" + exit 1 +fi + +# Remove 'v' prefix if present +VERSION=${VERSION#v} + +# Validate version format (semantic versioning) +if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in format X.Y.Z (e.g., 1.10.1)" + exit 1 +fi + +echo "Preparing release v${VERSION}..." + +# Check if tag already exists locally +if git rev-parse "v${VERSION}" >/dev/null 2>&1; then + echo "⚠️ WARNING: Tag v${VERSION} already exists locally" + read -p "Delete local tag and continue? (y/N) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + git tag -d "v${VERSION}" + echo "Local tag deleted." + else + echo "Release cancelled." + exit 1 + fi +fi + +# Check if tag exists on remote +if git ls-remote --tags origin "v${VERSION}" | grep -q "v${VERSION}"; then + echo "⚠️ WARNING: Tag v${VERSION} already exists on remote" + read -p "This will overwrite the remote tag. Continue? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Release cancelled." + exit 1 + fi +fi + +# Update version in __init__.py +echo "Updating version in pyouroboros/__init__.py..." +sed -i.bak "s/VERSION = \".*\"/VERSION = \"${VERSION}\"/" pyouroboros/__init__.py +rm -f pyouroboros/__init__.py.bak + +# Check if CHANGELOG needs updating +if ! grep -q "## \[v${VERSION}\]" CHANGELOG.md; then + echo "" + echo "⚠️ WARNING: CHANGELOG.md doesn't have an entry for v${VERSION}" + echo "Please add release notes to CHANGELOG.md before proceeding." + echo "" + read -p "Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Release cancelled." + git checkout pyouroboros/__init__.py + exit 1 + fi +fi + +# Show what will be committed +echo "" +echo "Changes to be committed:" +git diff pyouroboros/__init__.py + +echo "" +read -p "Create release v${VERSION}? (y/N) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Release cancelled." + git checkout pyouroboros/__init__.py + exit 1 +fi + +# Commit version change +git add pyouroboros/__init__.py +git commit -m "Bump version to v${VERSION}" + +# Create tag +echo "Creating tag v${VERSION}..." +git tag -a "v${VERSION}" -m "Release v${VERSION}" + +# Ask if user wants to push +echo "" +read -p "Push commit and tag to remote? (Y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Nn]$ ]]; then + echo "" + echo "✅ Release v${VERSION} prepared locally!" + echo "" + echo "Next steps:" + echo " 1. Review the changes: git show HEAD" + echo " 2. Push the commit: git push" + echo " 3. Push the tag: git push origin v${VERSION}" + if git ls-remote --tags origin "v${VERSION}" | grep -q "v${VERSION}"; then + echo " (or force push if overwriting: git push -f origin v${VERSION})" + fi +else + # Push commit + echo "Pushing commit..." + git push + + # Push tag (force if overwriting) + echo "Pushing tag v${VERSION}..." + if git ls-remote --tags origin "v${VERSION}" | grep -q "v${VERSION}"; then + echo "Tag exists on remote, force pushing..." + git push -f origin "v${VERSION}" + else + git push origin "v${VERSION}" + fi + + echo "" + echo "✅ Release v${VERSION} pushed!" + echo "" + echo "The GitHub Actions workflow will automatically:" + echo " - Build multi-arch Docker images (amd64, arm64)" + echo " - Push to GHCR: ghcr.io/styliteag/ouroboros:${VERSION}" + echo " - Create a GitHub release" + echo "" + echo "Monitor the workflow at: https://github.com/styliteag/ouroboros/actions" +fi diff --git a/setup.py b/setup.py index 2d8c5cef..4e594076 100644 --- a/setup.py +++ b/setup.py @@ -13,16 +13,20 @@ def get_requirements(filename="requirements.txt"): return list(filter(None, [req.strip() for req in requirements.split() if not req.startswith('#')])) +# Convert "custom" to a valid PEP 440 version for local builds +version = VERSION if VERSION != "custom" else "0.0.0.dev0" + + setup( name='ouroboros-cli', - version=VERSION, + version=version, author='circa10a', - author_email='caleblemoine@gmail.com', - maintainer='gmt2001', + author_email='wb@stylite.de', + maintainer='styliteag', description='Automatically update running docker containers', long_description=read('README.md'), long_description_content_type='text/markdown', - url='https://github.com/gmt2001/ouroboros', + url='https://github.com/styliteag/ouroboros', license='MIT', classifiers=['Programming Language :: Python', 'Programming Language :: Python :: 3.6',