diff --git a/.env b/.env index 0b9f2f4a..9452a2c1 100644 --- a/.env +++ b/.env @@ -4,8 +4,9 @@ # Essential DASHBOARD_DOMAIN=dashboard.openwisp.org API_DOMAIN=api.openwisp.org -# Image tag pinning -IMAGE_OWNER=openwisp +# Image location and tag pinning +# IMAGE_REGISTRY=docker.io +# IMAGE_NAMESPACE=openwisp # OPENWISP_VERSION: Image tag version (e.g., "25.10.0", "latest", or "edge") OPENWISP_VERSION=edge # SSH Credentials Configurations diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4664536e..385d3000 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,7 +138,7 @@ jobs: - name: Publish to Docker Hub if: ${{ success() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} run: | - make publish USER=docker.io/openwisp TAG=edge SKIP_BUILD=true SKIP_TESTS=true + make publish TAG=edge SKIP_BUILD=true SKIP_TESTS=true env: SELENIUM_HEADLESS: 1 @@ -154,4 +154,4 @@ jobs: - name: Publish to GitLab Container Registry if: ${{ success() && github.event_name == 'push' && github.ref == 'refs/heads/master' }} run: | - make publish USER=registry.gitlab.com/openwisp/docker-openwisp TAG=edge SKIP_BUILD=true SKIP_TESTS=true + make publish IMAGE_REGISTRY=registry.gitlab.com TAG=edge SKIP_BUILD=true SKIP_TESTS=true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65446c30..ebbb2b91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: - name: Release to Docker Hub run: | - make release USER=docker.io/openwisp + make release - name: Login to GitLab Container Registry uses: docker/login-action@v4.6.0 @@ -45,4 +45,4 @@ jobs: - name: Release to GitLab Container Registry run: | - make release USER=registry.gitlab.com/openwisp/docker-openwisp SKIP_BUILD=true + make release IMAGE_REGISTRY=registry.gitlab.com SKIP_BUILD=true diff --git a/Makefile b/Makefile index 496b41f5..1233ab58 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Find documentation in README.md under # the heading "Makefile Options". -# The .env file can override ?= variables in the Makefile (e.g. OPENWISP_VERSION, IMAGE_OWNER) +# The .env file can override ?= variables in the Makefile (e.g. OPENWISP_VERSION, IMAGE_REGISTRY) include .env # RELEASE_VERSION: version string used when tagging a new release. @@ -11,12 +11,25 @@ SHELL := /bin/bash default: compose-build -USER = docker.io/openwisp TAG = edge # OPENWISP_VERSION: image tag used for pulling/pushing images (e.g. "edge", "latest", "25.10.0") # Can be overridden via .env or command line. Not the same as RELEASE_VERSION OPENWISP_VERSION ?= edge -IMAGE_OWNER ?= openwisp +IMAGE_REGISTRY ?= docker.io +# Use Docker Hub when IMAGE_REGISTRY is unset or empty. +ifeq ($(strip $(IMAGE_REGISTRY)),) +override IMAGE_REGISTRY := docker.io +endif +# TODO: Remove IMAGE_OWNER compatibility in the next major release. +ifneq ($(strip $(IMAGE_OWNER)),) +$(warning IMAGE_OWNER is deprecated. Use IMAGE_REGISTRY and IMAGE_NAMESPACE instead.) +endif +# Fall back to either the old IMAGE_OWNER (if set) or "openwisp" when IMAGE_NAMESPACE is unset or empty. +# TODO: Remove IMAGE_OWNER compatibility in the next major release. +ifeq ($(strip $(IMAGE_NAMESPACE)),) +override IMAGE_NAMESPACE := $(if $(strip $(IMAGE_OWNER)),$(IMAGE_OWNER),openwisp) +endif +IMAGE_PREFIX = $(IMAGE_REGISTRY)/$(IMAGE_NAMESPACE) SKIP_PULL ?= false SKIP_BUILD ?= false SKIP_TESTS ?= false @@ -28,8 +41,7 @@ pull: for image in 'openwisp-base' 'openwisp-nfs' 'openwisp-api' 'openwisp-dashboard' \ 'openwisp-freeradius' 'openwisp-nginx' 'openwisp-openvpn' 'openwisp-postfix' \ 'openwisp-websocket' ; do \ - docker pull --quiet $(USER)/$${image}:$(OPENWISP_VERSION) || exit 1; \ - docker tag $(USER)/$${image}:$(OPENWISP_VERSION) $(IMAGE_OWNER)/$${image}:$(OPENWISP_VERSION) || exit 1; \ + docker pull --quiet $(IMAGE_PREFIX)/$${image}:$(OPENWISP_VERSION) || exit 1; \ done # Build @@ -41,20 +53,20 @@ base-build: for build_arg in $$BUILD_ARGS_FILE; do \ BUILD_ARGS+=" --build-arg $$build_arg"; \ done; \ - docker build --tag openwisp/openwisp-base:intermedia-system \ + docker build --tag $(IMAGE_PREFIX)/openwisp-base:intermedia-system \ --file ./images/openwisp_base/Dockerfile \ --target system ./images/; \ - docker build --tag openwisp/openwisp-base:intermedia-python \ + docker build --tag $(IMAGE_PREFIX)/openwisp-base:intermedia-python \ --file ./images/openwisp_base/Dockerfile \ --target openwisp_python ./images/ \ $$BUILD_ARGS; \ - docker build --tag $(IMAGE_OWNER)/openwisp-base:$(OPENWISP_VERSION) \ + docker build --tag $(IMAGE_PREFIX)/openwisp-base:$(OPENWISP_VERSION) \ --file ./images/openwisp_base/Dockerfile \ $$BUILD_ARGS \ ./images/ nfs-build: - docker build --tag $(IMAGE_OWNER)/openwisp-nfs:$(OPENWISP_VERSION) \ + docker build --tag $(IMAGE_PREFIX)/openwisp-nfs:$(OPENWISP_VERSION) \ --file ./images/openwisp_nfs/Dockerfile ./images/ compose-build: base-build @@ -72,7 +84,7 @@ develop-runtests: develop-pythontests: # OPENWISP_TEST_OPENVPN_IMAGE is used in tests to run OpenVPN tests with the # latest locally built image rather than the image published in the registries. - OPENWISP_TEST_OPENVPN_IMAGE=$(IMAGE_OWNER)/openwisp-openvpn:$(OPENWISP_VERSION) \ + OPENWISP_TEST_OPENVPN_IMAGE=$(IMAGE_PREFIX)/openwisp-openvpn:$(OPENWISP_VERSION) \ python3 tests/runtests.py # Development @@ -86,10 +98,10 @@ clean: docker compose stop &> /dev/null docker compose down --remove-orphans --volumes --rmi all &> /dev/null docker compose rm -svf &> /dev/null - docker rmi --force openwisp/openwisp-base:intermedia-system \ - openwisp/openwisp-base:intermedia-python \ - $(IMAGE_OWNER)/openwisp-base:$(OPENWISP_VERSION) \ - $(IMAGE_OWNER)/openwisp-nfs:$(OPENWISP_VERSION) \ + docker rmi --force $(IMAGE_PREFIX)/openwisp-base:intermedia-system \ + $(IMAGE_PREFIX)/openwisp-base:intermedia-python \ + $(IMAGE_PREFIX)/openwisp-base:$(OPENWISP_VERSION) \ + $(IMAGE_PREFIX)/openwisp-nfs:$(OPENWISP_VERSION) \ `docker images -f "dangling=true" -q` \ `docker images | grep openwisp/docker-openwisp | tr -s ' ' | cut -d ' ' -f 3` &> /dev/null @@ -119,16 +131,34 @@ publish: if [[ "$(SKIP_TESTS)" == "false" ]]; then \ make runtests; \ fi + # CI builds Docker Hub images before overriding IMAGE_REGISTRY for publishing. + # With SKIP_BUILD=true, use those existing namespace-only source tags. + if [[ "$(SKIP_BUILD)" == "true" ]]; then \ + source_prefix=$(IMAGE_NAMESPACE); \ + else \ + source_prefix=$(IMAGE_PREFIX); \ + fi; \ for image in 'openwisp-base' 'openwisp-nfs' 'openwisp-api' 'openwisp-dashboard' \ 'openwisp-freeradius' 'openwisp-nginx' 'openwisp-openvpn' 'openwisp-postfix' \ 'openwisp-websocket' ; do \ - docker tag $(IMAGE_OWNER)/$${image}:$(OPENWISP_VERSION) $(USER)/$${image}:$(TAG) || exit 1; \ - docker push $(USER)/$${image}:$(TAG) || exit 1; \ + source=$$source_prefix/$${image}:$(OPENWISP_VERSION); \ + destination=$(IMAGE_PREFIX)/$${image}:$(TAG); \ + if [ "$$source" != "$$destination" ]; then \ + docker tag "$$source" "$$destination" || exit 1; \ + fi; \ + docker push "$$destination" || exit 1; \ done release: make publish TAG=latest OPENWISP_VERSION=$(RELEASE_VERSION) SKIP_TESTS=true - make publish TAG=$(RELEASE_VERSION) OPENWISP_VERSION=$(RELEASE_VERSION) SKIP_BUILD=true SKIP_TESTS=true + # A custom-registry release rebuilds before publishing the versioned tag. + # SKIP_BUILD=true would otherwise use the Docker Hub source tags reserved for CI. + if [[ "$(SKIP_BUILD)" == "true" || "$(IMAGE_REGISTRY)" == "docker.io" ]]; then \ + skip_build=true; \ + else \ + skip_build=false; \ + fi; \ + make publish TAG=$(RELEASE_VERSION) OPENWISP_VERSION=$(RELEASE_VERSION) SKIP_BUILD=$$skip_build SKIP_TESTS=true bump: @if [ -z "$(VERSION)" ]; then \ diff --git a/docker-compose.yml b/docker-compose.yml index 07cbd808..ebb3562a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,13 +12,14 @@ x-celery-depends-on: &celery-depends-on services: dashboard: - image: ${IMAGE_OWNER:-openwisp}/openwisp-dashboard:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-dashboard:${OPENWISP_VERSION:-edge} restart: always build: context: images dockerfile: openwisp_dashboard/Dockerfile args: - IMAGE_OWNER: ${IMAGE_OWNER:-openwisp} + IMAGE_REGISTRY: ${IMAGE_REGISTRY:-docker.io} + IMAGE_NAMESPACE: ${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}} DASHBOARD_APP_PORT: 8000 OPENWISP_VERSION: ${OPENWISP_VERSION:-edge} env_file: @@ -38,13 +39,14 @@ services: - influxdb api: - image: ${IMAGE_OWNER:-openwisp}/openwisp-api:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-api:${OPENWISP_VERSION:-edge} restart: always build: context: images dockerfile: openwisp_api/Dockerfile args: - IMAGE_OWNER: ${IMAGE_OWNER:-openwisp} + IMAGE_REGISTRY: ${IMAGE_REGISTRY:-docker.io} + IMAGE_NAMESPACE: ${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}} API_APP_PORT: 8001 OPENWISP_VERSION: ${OPENWISP_VERSION:-edge} env_file: @@ -60,13 +62,14 @@ services: - dashboard websocket: - image: ${IMAGE_OWNER:-openwisp}/openwisp-websocket:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-websocket:${OPENWISP_VERSION:-edge} restart: always build: context: images dockerfile: openwisp_websocket/Dockerfile args: - IMAGE_OWNER: ${IMAGE_OWNER:-openwisp} + IMAGE_REGISTRY: ${IMAGE_REGISTRY:-docker.io} + IMAGE_NAMESPACE: ${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}} WEBSOCKET_APP_PORT: 8002 OPENWISP_VERSION: ${OPENWISP_VERSION:-edge} env_file: @@ -77,7 +80,7 @@ services: - dashboard celery: - image: ${IMAGE_OWNER:-openwisp}/openwisp-dashboard:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-dashboard:${OPENWISP_VERSION:-edge} # Keep this longer than Supervisor's stopwaitsecs to let it reap workers and exit. stop_grace_period: 120s restart: always @@ -94,7 +97,7 @@ services: network_mode: "${CELERY_SERVICE_NETWORK_MODE-service:openvpn}" celery_monitoring: - image: ${IMAGE_OWNER:-openwisp}/openwisp-dashboard:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-dashboard:${OPENWISP_VERSION:-edge} # Keep this longer than Supervisor's stopwaitsecs to let it reap workers and exit. stop_grace_period: 120s restart: always @@ -110,7 +113,7 @@ services: network_mode: "${CELERY_SERVICE_NETWORK_MODE-service:openvpn}" celerybeat: - image: ${IMAGE_OWNER:-openwisp}/openwisp-dashboard:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-dashboard:${OPENWISP_VERSION:-edge} restart: always environment: - MODULE_NAME=celerybeat @@ -124,7 +127,7 @@ services: - dashboard nginx: - image: ${IMAGE_OWNER:-openwisp}/openwisp-nginx:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-nginx:${OPENWISP_VERSION:-edge} restart: always build: context: images @@ -151,7 +154,7 @@ services: - websocket freeradius: - image: ${IMAGE_OWNER:-openwisp}/openwisp-freeradius:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-freeradius:${OPENWISP_VERSION:-edge} restart: always build: context: images @@ -167,7 +170,7 @@ services: - dashboard postfix: - image: ${IMAGE_OWNER:-openwisp}/openwisp-postfix:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-postfix:${OPENWISP_VERSION:-edge} restart: always build: context: images @@ -178,7 +181,7 @@ services: - openwisp_certs:/etc/ssl/mail openvpn: - image: ${IMAGE_OWNER:-openwisp}/openwisp-openvpn:${OPENWISP_VERSION:-edge} + image: ${IMAGE_REGISTRY:-docker.io}/${IMAGE_NAMESPACE:-${IMAGE_OWNER:-openwisp}}/openwisp-openvpn:${OPENWISP_VERSION:-edge} restart: on-failure build: context: images diff --git a/docs/developer/instructions.rst b/docs/developer/instructions.rst index 3fe38bc7..3fbbb271 100644 --- a/docs/developer/instructions.rst +++ b/docs/developer/instructions.rst @@ -132,10 +132,10 @@ Makefile Options Most commonly used: -- ``make start [IMAGE_OWNER=docker-username] [OPENWISP_VERSION=version]``: - Start OpenWISP containers on your server. -- ``make pull [IMAGE_OWNER=docker-username] [OPENWISP_VERSION=version]``: - Pull images from the registry. +- ``make start [IMAGE_REGISTRY=registry] [IMAGE_NAMESPACE=namespace] + [OPENWISP_VERSION=version]``: Start OpenWISP containers on your server. +- ``make pull [IMAGE_REGISTRY=registry] [IMAGE_NAMESPACE=namespace] + [OPENWISP_VERSION=version]``: Pull images from the registry. - ``make stop``: Stop OpenWISP containers on your server. - ``make develop``: Bundle all the commands required to build the images and run containers. @@ -146,7 +146,7 @@ Most commonly used: Other options: -- ``make publish [IMAGE_OWNER=docker-username] +- ``make publish [IMAGE_REGISTRY=registry] [IMAGE_NAMESPACE=namespace] [OPENWISP_VERSION=version]``: Build, test, and publish images. - ``make python-build``: Generate a random Django secret and set it in the ``.env`` file. diff --git a/docs/user/settings.rst b/docs/user/settings.rst index 376263bb..92277f34 100644 --- a/docs/user/settings.rst +++ b/docs/user/settings.rst @@ -1146,13 +1146,34 @@ X509 Certificates Misc Services ------------- +``IMAGE_REGISTRY`` +~~~~~~~~~~~~~~~~~~ + +- **Explanation:** Docker registry host for OpenWISP images. Change this + together with ``IMAGE_NAMESPACE`` when using a custom registry. +- **Valid Values:** A valid Docker registry host. +- **Default:** ``docker.io``. + +``IMAGE_NAMESPACE`` +~~~~~~~~~~~~~~~~~~~ + +- **Explanation:** Docker repository namespace for OpenWISP images. Change + this if you're using a custom registry. +- **Valid Values:** A valid Docker repository namespace or path. +- **Default:** ``openwisp``. + ``IMAGE_OWNER`` ~~~~~~~~~~~~~~~ -- **Explanation:** Docker registry namespace for OpenWISP images. Change - this only if you are using custom-built images or a private registry. -- **Valid Values:** A valid Docker registry namespace. -- **Default:** ``openwisp`` +.. warning:: + + ``IMAGE_OWNER`` is deprecated and will be removed in the next major + release. Use ``IMAGE_NAMESPACE`` instead. Make commands emit a + deprecation warning when ``IMAGE_OWNER`` is set. + +- **Explanation:** Legacy Docker repository namespace. It is used only + when ``IMAGE_NAMESPACE`` is unset during the deprecation period. +- **Valid Values:** A valid Docker repository namespace or path. ``REDIS_HOST`` ~~~~~~~~~~~~~~ diff --git a/images/openwisp_api/Dockerfile b/images/openwisp_api/Dockerfile index 78813a66..9f41fb6b 100644 --- a/images/openwisp_api/Dockerfile +++ b/images/openwisp_api/Dockerfile @@ -1,7 +1,8 @@ # hadolint ignore=DL3007 -ARG IMAGE_OWNER=openwisp +ARG IMAGE_REGISTRY=docker.io +ARG IMAGE_NAMESPACE=openwisp ARG OPENWISP_VERSION=edge -FROM ${IMAGE_OWNER}/openwisp-base:${OPENWISP_VERSION} +FROM ${IMAGE_REGISTRY}/${IMAGE_NAMESPACE}/openwisp-base:${OPENWISP_VERSION} WORKDIR /opt/openwisp/ diff --git a/images/openwisp_dashboard/Dockerfile b/images/openwisp_dashboard/Dockerfile index 3c30e529..f3716208 100644 --- a/images/openwisp_dashboard/Dockerfile +++ b/images/openwisp_dashboard/Dockerfile @@ -1,7 +1,8 @@ # hadolint ignore=DL3007 -ARG IMAGE_OWNER=openwisp +ARG IMAGE_REGISTRY=docker.io +ARG IMAGE_NAMESPACE=openwisp ARG OPENWISP_VERSION=edge -FROM ${IMAGE_OWNER}/openwisp-base:${OPENWISP_VERSION} +FROM ${IMAGE_REGISTRY}/${IMAGE_NAMESPACE}/openwisp-base:${OPENWISP_VERSION} WORKDIR /opt/openwisp/ # Location: /opt/openwisp/ diff --git a/images/openwisp_websocket/Dockerfile b/images/openwisp_websocket/Dockerfile index 3f1a7333..8c629bc1 100644 --- a/images/openwisp_websocket/Dockerfile +++ b/images/openwisp_websocket/Dockerfile @@ -1,7 +1,8 @@ # hadolint ignore=DL3007 -ARG IMAGE_OWNER=openwisp +ARG IMAGE_REGISTRY=docker.io +ARG IMAGE_NAMESPACE=openwisp ARG OPENWISP_VERSION=edge -FROM ${IMAGE_OWNER}/openwisp-base:${OPENWISP_VERSION} +FROM ${IMAGE_REGISTRY}/${IMAGE_NAMESPACE}/openwisp-base:${OPENWISP_VERSION} WORKDIR /opt/openwisp/ diff --git a/tests/runtests.py b/tests/runtests.py index 1f971c8b..f556ffa7 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -1427,41 +1427,97 @@ def test_nginx_development_headers_clear_hsts(self): def test_workflows_publish_to_gitlab_registry(self): repository_root = Path(__file__).resolve().parents[1] - registry = "registry.gitlab.com/openwisp/docker-openwisp" + registry = "registry.gitlab.com" ci_workflow = (repository_root / ".github" / "workflows" / "ci.yml").read_text() release_workflow = ( repository_root / ".github" / "workflows" / "release.yml" ).read_text() self.assertIn( - f"make publish USER={registry} TAG=edge SKIP_BUILD=true SKIP_TESTS=true", + f"make publish IMAGE_REGISTRY={registry} TAG=edge " + "SKIP_BUILD=true SKIP_TESTS=true", ci_workflow, ) self.assertIn( - f"make release USER={registry} SKIP_BUILD=true", + f"make release IMAGE_REGISTRY={registry} SKIP_BUILD=true", release_workflow, ) - def test_docker_compose_images_use_image_owner(self): - """Verify OpenWISP services in docker-compose.yml use IMAGE_OWNER.""" + def test_docker_compose_config_uses_image_registry_and_namespace(self): + """Verify Compose resolves configured registry and namespace settings.""" repository_root = Path(__file__).resolve().parents[1] - compose_content = (repository_root / "docker-compose.yml").read_text() - openwisp_services = [ - "dashboard", - "api", - "websocket", - "celery", - "celery_monitoring", - "celerybeat", - "nginx", - "freeradius", - "postfix", - "openvpn", - ] - for service in openwisp_services: + registry = "registry.example.com" + namespace = "openwisp-images" + version = "25.10.4" + environment = os.environ.copy() + environment.update( + { + "IMAGE_OWNER": "", + "IMAGE_REGISTRY": registry, + "IMAGE_NAMESPACE": namespace, + "OPENWISP_VERSION": version, + } + ) + result = subprocess.run( + ["docker", "compose", "config", "--format", "json"], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertEqual(result.returncode, 0, result.stderr) + services = json.loads(result.stdout)["services"] + expected_images = { + "dashboard": "openwisp-dashboard", + "api": "openwisp-api", + "websocket": "openwisp-websocket", + "celery": "openwisp-dashboard", + "celery_monitoring": "openwisp-dashboard", + "celerybeat": "openwisp-dashboard", + "nginx": "openwisp-nginx", + "freeradius": "openwisp-freeradius", + "postfix": "openwisp-postfix", + "openvpn": "openwisp-openvpn", + } + for service, image in expected_images.items(): with self.subTest(service=service): - self.assertRegex( - compose_content, - rf"{service}:\s+image:\s+\${{IMAGE_OWNER:-openwisp}}/openwisp-", + self.assertEqual( + services[service]["image"], + f"{registry}/{namespace}/{image}:{version}", + ) + for service in ("dashboard", "api", "websocket"): + with self.subTest(service=service): + arguments = services[service]["build"]["args"] + self.assertEqual(arguments["IMAGE_REGISTRY"], registry) + self.assertEqual(arguments["IMAGE_NAMESPACE"], namespace) + + legacy_namespace = "legacy-images" + environment["IMAGE_NAMESPACE"] = "" + environment["IMAGE_OWNER"] = legacy_namespace + result = subprocess.run( + ["docker", "compose", "config", "--format", "json"], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertEqual(result.returncode, 0, result.stderr) + services = json.loads(result.stdout)["services"] + for service, image in expected_images.items(): + with self.subTest(service=service, image_namespace=legacy_namespace): + self.assertEqual( + services[service]["image"], + f"{registry}/{legacy_namespace}/{image}:{version}", + ) + for service in ("dashboard", "api", "websocket"): + with self.subTest(service=service, image_namespace=legacy_namespace): + self.assertEqual( + services[service]["build"]["args"]["IMAGE_REGISTRY"], registry + ) + self.assertEqual( + services[service]["build"]["args"]["IMAGE_NAMESPACE"], + legacy_namespace, ) @contextmanager @@ -1489,6 +1545,9 @@ def _makefile_test_environment(self): docker_command.chmod(0o755) (tmpdir / "Makefile").write_text((repository_root / "Makefile").read_text()) (tmpdir / ".env").write_text("") + version_file = tmpdir / "images" / "common" / "openwisp" / "VERSION" + version_file.parent.mkdir(parents=True) + version_file.write_text("25.10.4") environment = os.environ.copy() environment["DOCKER_LOG"] = str(docker_log) environment["PATH"] = f"{bin_directory}:{environment['PATH']}" @@ -1515,51 +1574,167 @@ def test_make_start_rejects_development_mode(self): self.assertIn("Set DEV_MODE=False", development_start.stdout) self.assertEqual(docker_log.read_text(), "") - def test_makefile_pulls_from_docker_hub_and_fails_on_image_command_error(self): - """Verify Docker Hub pulls and Makefile command failure propagation.""" + def test_makefile_uses_configured_image_prefixes(self): + """Verify image commands use configured prefixes and propagate failures.""" with self._makefile_test_environment() as (run_make, docker_log, environment): - pull = run_make("pull", "OPENWISP_VERSION=25.10.4") + registry = "registry.example.com" + namespace = "openwisp-images" + image_prefix = f"{registry}/{namespace}" + pull = run_make( + "pull", + f"IMAGE_REGISTRY={registry}", + f"IMAGE_NAMESPACE={namespace}", + "OPENWISP_VERSION=25.10.4", + ) self.assertEqual(pull.returncode, 0, pull.stderr) commands = docker_log.read_text().splitlines() + self.assertEqual(len(commands), 9, "The Makefile must pull all images.") + self.assertTrue( + all( + command.startswith(f"pull --quiet {image_prefix}/") + for command in commands + ), + "The configured registry and namespace must be used for pulls.", + ) + + docker_log.write_text("") + empty_registry_pull = run_make( + "pull", + "IMAGE_REGISTRY=", + f"IMAGE_NAMESPACE={namespace}", + "OPENWISP_VERSION=25.10.4", + ) self.assertEqual( - len(commands), 18, "The Makefile must pull and tag all images." + empty_registry_pull.returncode, 0, empty_registry_pull.stderr ) self.assertTrue( all( - command.startswith("pull --quiet docker.io/openwisp/") - for command in commands[::2] + command.startswith(f"pull --quiet docker.io/{namespace}/") + for command in docker_log.read_text().splitlines() ), - "The default pull registry must be Docker Hub.", + "An empty registry must use docker.io.", ) - for command in ("pull", "tag"): - with self.subTest(target="pull", command=command): - environment["FAIL_COMMAND"] = command - failed_pull = run_make("pull", "OPENWISP_VERSION=25.10.4") - self.assertNotEqual( - failed_pull.returncode, - 0, - f"A failed image {command} must fail make pull.", + docker_log.write_text("") + legacy_namespace = "legacy-images" + legacy_prefix = f"{registry}/{legacy_namespace}" + legacy_pull = run_make( + "pull", + f"IMAGE_REGISTRY={registry}", + f"IMAGE_OWNER={legacy_namespace}", + "OPENWISP_VERSION=25.10.4", + ) + self.assertEqual(legacy_pull.returncode, 0, legacy_pull.stderr) + self.assertIn("IMAGE_OWNER is deprecated", legacy_pull.stderr) + self.assertTrue( + all( + command.startswith(f"pull --quiet {legacy_prefix}/") + for command in docker_log.read_text().splitlines() + ), + "The legacy image prefix must be used during the deprecation period.", + ) + + for image_owner, expected_namespace in ( + (legacy_namespace, legacy_namespace), + ("", "openwisp"), + ): + with self.subTest(image_owner=image_owner): + docker_log.write_text("") + pull = run_make( + "pull", + f"IMAGE_REGISTRY={registry}", + "IMAGE_NAMESPACE=", + f"IMAGE_OWNER={image_owner}", + "OPENWISP_VERSION=25.10.4", + ) + self.assertEqual(pull.returncode, 0, pull.stderr) + self.assertTrue( + all( + command.startswith( + f"pull --quiet {registry}/{expected_namespace}/" + ) + for command in docker_log.read_text().splitlines() + ), + "An empty namespace must use the configured fallback.", ) + environment["FAIL_COMMAND"] = "pull" + failed_pull = run_make("pull", "OPENWISP_VERSION=25.10.4") + self.assertNotEqual( + failed_pull.returncode, + 0, + "A failed image pull must fail make pull.", + ) + environment.pop("FAIL_COMMAND") docker_log.write_text("") publish_arguments = ( "publish", - "USER=docker.io/openwisp", - "TAG=25.10.4", + f"IMAGE_REGISTRY={registry}", + f"IMAGE_NAMESPACE={namespace}", + "TAG=latest", "OPENWISP_VERSION=25.10.4", "SKIP_BUILD=true", "SKIP_TESTS=true", ) publish = run_make(*publish_arguments) self.assertEqual(publish.returncode, 0, publish.stderr) - self.assertFalse( - any( - command.startswith("rmi ") - for command in docker_log.read_text().splitlines() + commands = docker_log.read_text().splitlines() + self.assertEqual( + len(commands), 18, "Publishing must tag and push all images." + ) + self.assertTrue( + all( + command.startswith(f"tag {namespace}/") + and f" {image_prefix}/" in command + for command in commands[::2] + ), + "Publishing must use the configured destination prefix.", + ) + self.assertTrue( + all( + command.startswith(f"push {image_prefix}/") + for command in commands[1::2] ), - "Publishing must retain the source tags for another registry.", + "Publishing must push the configured destination prefix.", + ) + + docker_log.write_text("") + publish = run_make( + "publish", + f"IMAGE_REGISTRY={registry}", + f"IMAGE_NAMESPACE={namespace}", + "TAG=latest", + "OPENWISP_VERSION=25.10.4", + "SKIP_TESTS=true", + ) + self.assertEqual(publish.returncode, 0, publish.stderr) + tag_commands = [ + command + for command in docker_log.read_text().splitlines() + if command.startswith("tag ") + ] + self.assertEqual(len(tag_commands), 9) + self.assertTrue( + all( + command.startswith(f"tag {image_prefix}/") + for command in tag_commands + ), + "Publishing after a build must use the configured source prefix.", + ) + + docker_log.write_text("") + release = run_make( + "release", + f"IMAGE_REGISTRY={registry}", + f"IMAGE_NAMESPACE={namespace}", + ) + self.assertEqual(release.returncode, 0, release.stderr) + commands = docker_log.read_text().splitlines() + self.assertEqual( + commands.count("compose build --parallel"), + 2, + "Custom-registry releases must rebuild before the versioned publish.", ) for command in ("tag", "push"):