Skip to content

Feature Request: Add backup, restore, and prune commands to 'tutor images' #1373

Description

@sagrodat

Is your feature request related to a problem? Please describe.

When developing custom themes or MFEs locally (using dev mode) and iterating through them quickly (moving changes from dev mode on Machine1 to prod environment - local mode on Machine2) I find myself having to do a combination of actions repeatedly, pretty often. Namely:

  • creating an additional (backup) tag for currently used images in prod environment in case the new pull breaks something
  • restoring to the previous (backed up) version of the image if the new pulled image broke something (because of discrepancies between the dev and local environment).
  • deleting (pruning) old image tags no longer required since newly pulled image works correctly

Describe the solution you'd like

A new subset of commands for the tutor images ... command.
backup

Usage: tutor images backup [OPTIONS] {openedx|openedx-dev|mfe|all}

  Create a backup tag for the current images.

Options:
  -t, --tag TEXT  Tag suffix (default: BACKUP)
  -h, --help      Show this message and exit.

restore

Usage: tutor images restore [OPTIONS] {openedx|openedx-dev|mfe|all}

  Restore a broken image from a backup tag.

Options:
  -t, --tag TEXT  Tag suffix to restore from
  -h, --help      Show this message and exit.

prune

Usage: tutor images prune [OPTIONS] {openedx|openedx-dev|mfe|all}

  Remove local backup images to free up space.

Options:
  -t, --tag TEXT  Tag suffix to remove
  -h, --help      Show this message and exit.

##Describe alternatives you've considered
Well, up until a point I was doing all of that by hand using

docker tag OLD_IMAGE_TAG NEW_IMAGE_TAG

Recently after being fed up with the amount of manual input it required I looked up the docs and implemented a tutor plugin as follows. If it's handy enough maybe this could become part of Tutor itself.

import click
import subprocess
from tutor.commands.images import images_command
from utils import get_tutor_config

# 1. Map simple CLI names to Tutor's internal configuration keys
IMAGE_KEYS = {
    "openedx": "DOCKER_IMAGE_OPENEDX",
    "openedx-dev": "DOCKER_IMAGE_OPENEDX_DEV",
    "mfe": "MFE_DOCKER_IMAGE",
}

def get_image_string(target_name):
    config = get_tutor_config()
    config_key = IMAGE_KEYS[target_name]

    print(config.get(config_key,"test"))
    return config.get(config_key)

def generate_backup_name(image, tag_suffix):
    """Safely creates a backup tag name, preventing invalid Docker syntax."""
    if ":" in image.split("/")[-1]:
        # If it already has a tag (repo:20.0.1), make it repo:20.0.1-BACKUP
        return f"{image}-{tag_suffix}"
    else:
        # If no tag is present, make it repo:BACKUP
        return f"{image}:{tag_suffix}"

# --- COMMAND: BACKUP ---
@click.command()
@click.argument("target", type=click.Choice(list(IMAGE_KEYS.keys()) + ["all"]))
@click.option("-t", "--tag", default="BACKUP", help="Tag suffix (default: BACKUP)")
def backup(target, tag):
    """Tag current Docker images as backups before pulling/building new ones."""
    targets = list(IMAGE_KEYS.keys()) if target == "all" else [target]
    
    for t in targets:
        image = get_image_string(t)
        backup = generate_backup_name(image, tag)
        click.echo(f"Creating backup: {image} -> {backup}")
        subprocess.run(["docker", "tag", image, backup], check=True)
        
    click.echo("Backup process complete.")

# --- COMMAND: RESTORE ---
@click.command()
@click.argument("target", type=click.Choice(list(IMAGE_KEYS.keys()) + ["all"]))
@click.option("-t", "--tag", default="BACKUP", help="Tag suffix to restore from")
def restore(target, tag):
    """Restore a broken image by reverting to the backup tag."""
    targets = list(IMAGE_KEYS.keys()) if target == "all" else [target]
    
    for t in targets:
        original = get_image_string(t)
        backup = generate_backup_name(original, tag)
        click.echo(f"Restoring backup: {backup} -> {original}")
        # Retag the backup as the primary image name
        subprocess.run(["docker", "tag", backup, original], check=True)
        
    click.echo("Restore complete. You may need to run `tutor local restart`.")

# --- COMMAND: CLEANUP/REMOVE ---
@click.command()
@click.argument("target", type=click.Choice(list(IMAGE_KEYS.keys()) + ["all"]))
@click.option("-t", "--tag", default="BACKUP", help="Tag suffix to remove")
def prune(target, tag):
    """Remove backup images to free up disk space."""
    targets = list(IMAGE_KEYS.keys()) if target == "all" else [target]
    
    for t in targets:
        image = get_image_string(t)
        backup = generate_backup_name(image, tag)
        click.echo(f"Removing backup image: {backup}")
        # check=False prevents the script from crashing if the backup doesn't exist
        subprocess.run(["docker", "rmi", backup], check=False)
        
    click.echo("Cleanup complete.")

# Register all commands with Tutor
images_command.add_command(backup)
images_command.add_command(restore)
images_command.add_command(prune)

Additional context

  • I am not an experienced developer yet so I might be missing something about the whole docker images workflow
  • I maintain custom cloned MFEs (private) and that's what I mean by "developing them"
  • I am not sure if that is a niche problem or something that would be appreciated by other tutor site operators hence this issue
  • Since thanks to the flexibility of tutor this solution may be implemented as a plugin, maybe it's not worth merging with base tutor
  • I already use a plugin that links my custom registry by leveraging the IMAGES_PULL and IMAGES_PUSH hooks.
  • I know some Tutor plugins add new images to IMAGES_BUILD_REQUIRED (i.e. Aspects) so IMAGE_KEYS from my implementation would need to be linked with IMAGES_BUILD_REQUIRED somehow?
  • Code is illustrative only as it can't be run directly. It uses my util function which I would say isself explanatory (just a wrapper around tutor's config so I don't have to find the root everytime)

If this is something worse pursuing I'd be happy to create a PR after possibly some implementation suggestions.

AI Declaration

Plugin created w the help of gemini 3.1 Pro

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Pending Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions