diff options
| -rw-r--r-- | recipes-containers/container-registry/README.md | 245 | ||||
| -rw-r--r-- | tests/conftest.py | 25 | ||||
| -rw-r--r-- | tests/test_container_registry_script.py | 926 |
3 files changed, 1195 insertions, 1 deletions
diff --git a/recipes-containers/container-registry/README.md b/recipes-containers/container-registry/README.md index 1a0f74eb..470442b9 100644 --- a/recipes-containers/container-registry/README.md +++ b/recipes-containers/container-registry/README.md | |||
| @@ -171,6 +171,247 @@ DOCKER_REGISTRY_INSECURE = "localhost:5000" | |||
| 171 | CONTAINER_REGISTRY_STORAGE = "/data/container-registry" | 171 | CONTAINER_REGISTRY_STORAGE = "/data/container-registry" |
| 172 | ``` | 172 | ``` |
| 173 | 173 | ||
| 174 | ## Authentication | ||
| 175 | |||
| 176 | Support for pushing to authenticated registries (Docker Hub, GitHub Container Registry, private registries). | ||
| 177 | |||
| 178 | ### Authentication Modes | ||
| 179 | |||
| 180 | | Mode | BitBake | Script | Description | | ||
| 181 | |------|---------|--------|-------------| | ||
| 182 | | `none` | Yes | Yes | No authentication (default, local registries) | | ||
| 183 | | `home` | Yes | Yes | Use `~/.docker/config.json` (opt-in) | | ||
| 184 | | `authfile` | Yes | Yes | Explicit Docker-style config.json path | | ||
| 185 | | `credsfile` | Yes | Yes | Simple key=value credentials file | | ||
| 186 | | `env` | No | Yes | Environment variables (script only) | | ||
| 187 | |||
| 188 | ### BitBake Configuration | ||
| 189 | |||
| 190 | ```bitbake | ||
| 191 | # Authentication mode | ||
| 192 | CONTAINER_REGISTRY_AUTH_MODE = "none" # Default, no auth | ||
| 193 | CONTAINER_REGISTRY_AUTH_MODE = "home" # Use ~/.docker/config.json | ||
| 194 | CONTAINER_REGISTRY_AUTH_MODE = "authfile" # Explicit auth file | ||
| 195 | CONTAINER_REGISTRY_AUTH_MODE = "credsfile" # Simple credentials file | ||
| 196 | |||
| 197 | # For authfile mode | ||
| 198 | CONTAINER_REGISTRY_AUTHFILE = "/path/to/docker-config.json" | ||
| 199 | |||
| 200 | # For credsfile mode | ||
| 201 | CONTAINER_REGISTRY_CREDSFILE = "${HOME}/.config/container-registry/credentials" | ||
| 202 | ``` | ||
| 203 | |||
| 204 | ### Script Options | ||
| 205 | |||
| 206 | ```bash | ||
| 207 | # Use existing Docker login (~/.docker/config.json) | ||
| 208 | container-registry.sh push --use-home-auth | ||
| 209 | |||
| 210 | # Explicit auth file | ||
| 211 | container-registry.sh push --authfile /path/to/docker-config.json | ||
| 212 | |||
| 213 | # Simple credentials file | ||
| 214 | container-registry.sh push --credsfile ~/.config/container-registry/credentials | ||
| 215 | |||
| 216 | # Environment variables | ||
| 217 | export CONTAINER_REGISTRY_AUTH_MODE=env | ||
| 218 | export CONTAINER_REGISTRY_TOKEN=ghp_xxxxx | ||
| 219 | container-registry.sh push | ||
| 220 | |||
| 221 | # Direct credentials (less secure - in shell history) | ||
| 222 | container-registry.sh push --creds user:password | ||
| 223 | container-registry.sh push --token ghp_xxxxx | ||
| 224 | |||
| 225 | # Import from authenticated source registry | ||
| 226 | container-registry.sh import ghcr.io/org/private:v1 --src-authfile ~/.docker/config.json | ||
| 227 | container-registry.sh import ghcr.io/org/private:v1 --src-credsfile ~/.config/ghcr-creds | ||
| 228 | ``` | ||
| 229 | |||
| 230 | ### Credentials File Format | ||
| 231 | |||
| 232 | Simple key=value format (not checked into source control): | ||
| 233 | |||
| 234 | ```bash | ||
| 235 | # ~/.config/container-registry/credentials | ||
| 236 | # Username/password OR token (token takes precedence) | ||
| 237 | CONTAINER_REGISTRY_USER=myuser | ||
| 238 | CONTAINER_REGISTRY_PASSWORD=mypassword | ||
| 239 | |||
| 240 | # Or for token-based auth (GitHub, GitLab, etc.): | ||
| 241 | CONTAINER_REGISTRY_TOKEN=ghp_xxxxxxxxxxxx | ||
| 242 | ``` | ||
| 243 | |||
| 244 | Create with proper permissions: | ||
| 245 | ```bash | ||
| 246 | mkdir -p ~/.config/container-registry | ||
| 247 | cat > ~/.config/container-registry/credentials << 'EOF' | ||
| 248 | CONTAINER_REGISTRY_TOKEN=ghp_xxxxxxxxxxxx | ||
| 249 | EOF | ||
| 250 | chmod 600 ~/.config/container-registry/credentials | ||
| 251 | ``` | ||
| 252 | |||
| 253 | ### CI/CD Integration | ||
| 254 | |||
| 255 | For CI/CD with BitBake, use `credsfile` mode and have CI write the credentials file: | ||
| 256 | |||
| 257 | ```yaml | ||
| 258 | # Example GitHub Actions | ||
| 259 | - name: Setup registry credentials | ||
| 260 | run: | | ||
| 261 | mkdir -p ~/.config/container-registry | ||
| 262 | echo "CONTAINER_REGISTRY_TOKEN=${{ secrets.GHCR_TOKEN }}" > ~/.config/container-registry/credentials | ||
| 263 | chmod 600 ~/.config/container-registry/credentials | ||
| 264 | |||
| 265 | - name: Build and push | ||
| 266 | run: | | ||
| 267 | bitbake container-base | ||
| 268 | ./container-registry/container-registry.sh push --credsfile ~/.config/container-registry/credentials | ||
| 269 | ``` | ||
| 270 | |||
| 271 | For script-only usage, environment variables are simpler: | ||
| 272 | |||
| 273 | ```yaml | ||
| 274 | - name: Push to registry | ||
| 275 | env: | ||
| 276 | CONTAINER_REGISTRY_AUTH_MODE: env | ||
| 277 | CONTAINER_REGISTRY_TOKEN: ${{ secrets.GHCR_TOKEN }} | ||
| 278 | run: ./container-registry/container-registry.sh push | ||
| 279 | ``` | ||
| 280 | |||
| 281 | ### Security Notes | ||
| 282 | |||
| 283 | - **No credentials in BitBake variables**: Like git fetcher, avoid passwords in metadata that gets logged/shared | ||
| 284 | - **Use file-based auth**: Credentials files can be excluded from version control and have proper permissions | ||
| 285 | - **Opt-in for home directory**: `home` mode requires explicit opt-in (like `BB_USE_HOME_NPMRC`) | ||
| 286 | - **Prefer tokens over passwords**: Tokens can be scoped and revoked | ||
| 287 | |||
| 288 | ## Secure Registry Mode | ||
| 289 | |||
| 290 | Enable TLS and authentication for the local registry (opt-in). | ||
| 291 | |||
| 292 | ### Configuration | ||
| 293 | |||
| 294 | ```bitbake | ||
| 295 | # Enable secure mode | ||
| 296 | CONTAINER_REGISTRY_SECURE = "1" | ||
| 297 | |||
| 298 | # Optional: custom username (default: yocto) | ||
| 299 | CONTAINER_REGISTRY_USERNAME = "myuser" | ||
| 300 | |||
| 301 | # Optional: explicit password (default: auto-generate) | ||
| 302 | CONTAINER_REGISTRY_PASSWORD = "mypassword" | ||
| 303 | |||
| 304 | # Optional: certificate validity (days) | ||
| 305 | CONTAINER_REGISTRY_CERT_DAYS = "365" # Server cert | ||
| 306 | CONTAINER_REGISTRY_CA_DAYS = "3650" # CA cert (10 years) | ||
| 307 | |||
| 308 | # Optional: additional SAN entries for server cert | ||
| 309 | CONTAINER_REGISTRY_CERT_SAN = "DNS:myhost.local,IP:192.168.1.100" | ||
| 310 | ``` | ||
| 311 | |||
| 312 | ### Setup | ||
| 313 | |||
| 314 | PKI (CA and server certificates) is auto-generated during the bitbake build: | ||
| 315 | |||
| 316 | ```bash | ||
| 317 | # Generate script AND PKI infrastructure | ||
| 318 | bitbake container-registry-index -c generate_registry_script | ||
| 319 | |||
| 320 | # Build target image (CA cert is automatically baked in) | ||
| 321 | bitbake container-image-host | ||
| 322 | ``` | ||
| 323 | |||
| 324 | The `generate_registry_script` task automatically generates the PKI if it doesn't exist. No manual steps required. | ||
| 325 | |||
| 326 | ### Starting the Registry | ||
| 327 | |||
| 328 | To actually run the registry (for pushing/pulling images): | ||
| 329 | |||
| 330 | ```bash | ||
| 331 | # Start registry (generates htpasswd auth on first run) | ||
| 332 | ./container-registry/container-registry.sh start | ||
| 333 | ``` | ||
| 334 | |||
| 335 | Output: | ||
| 336 | ``` | ||
| 337 | Setting up authentication... | ||
| 338 | Password saved to: .../auth/password | ||
| 339 | Starting SECURE container registry... | ||
| 340 | URL: https://localhost:5000 | ||
| 341 | ``` | ||
| 342 | |||
| 343 | ### Generated Files | ||
| 344 | |||
| 345 | ``` | ||
| 346 | ${CONTAINER_REGISTRY_STORAGE}/ | ||
| 347 | ├── pki/ # Generated by: bitbake ... -c generate_registry_script | ||
| 348 | │ ├── ca.key # CA private key (600) | ||
| 349 | │ ├── ca.crt # CA certificate - baked into target images | ||
| 350 | │ ├── server.key # Server private key (600) | ||
| 351 | │ └── server.crt # Server certificate with SAN | ||
| 352 | ├── auth/ # Generated by: container-registry.sh start | ||
| 353 | │ ├── htpasswd # Bcrypt credentials for registry | ||
| 354 | │ └── password # Plaintext password for reference (600) | ||
| 355 | └── ... | ||
| 356 | ``` | ||
| 357 | |||
| 358 | ### Push (auto-uses credentials) | ||
| 359 | |||
| 360 | ```bash | ||
| 361 | ./container-registry/container-registry.sh push | ||
| 362 | # Automatically uses: | ||
| 363 | # --dest-cert-dir=.../pki | ||
| 364 | # --dest-creds=yocto:<auto-password> | ||
| 365 | ``` | ||
| 366 | |||
| 367 | ### Target Integration | ||
| 368 | |||
| 369 | Install CA certificate on target images: | ||
| 370 | |||
| 371 | ```bitbake | ||
| 372 | # Automatically included when CONTAINER_REGISTRY_SECURE = "1" | ||
| 373 | # and IMAGE_FEATURES:append = " container-registry" | ||
| 374 | IMAGE_INSTALL:append = " container-registry-ca" | ||
| 375 | ``` | ||
| 376 | |||
| 377 | This installs CA cert to: | ||
| 378 | - `/etc/docker/certs.d/{registry}/ca.crt` (Docker) | ||
| 379 | - `/etc/containers/certs.d/{registry}/ca.crt` (Podman/CRI-O) | ||
| 380 | - `/usr/local/share/ca-certificates/container-registry-ca.crt` (system) | ||
| 381 | |||
| 382 | ### vdkr with Secure Registry | ||
| 383 | |||
| 384 | ```bash | ||
| 385 | # Pass secure mode and CA cert to VM | ||
| 386 | vdkr --secure-registry --ca-cert $STORAGE/pki/ca.crt pull myimage | ||
| 387 | |||
| 388 | # Or with credentials | ||
| 389 | vdkr --secure-registry --ca-cert $STORAGE/pki/ca.crt \ | ||
| 390 | --registry-user yocto --registry-password mypass pull myimage | ||
| 391 | ``` | ||
| 392 | |||
| 393 | ### Verification | ||
| 394 | |||
| 395 | ```bash | ||
| 396 | # Verify TLS with curl | ||
| 397 | curl --cacert $STORAGE/pki/ca.crt \ | ||
| 398 | -u yocto:$(cat $STORAGE/auth/password) \ | ||
| 399 | https://localhost:5000/v2/_catalog | ||
| 400 | |||
| 401 | # Check server certificate SAN | ||
| 402 | openssl x509 -in $STORAGE/pki/server.crt -noout -text | grep -A1 "Subject Alternative Name" | ||
| 403 | ``` | ||
| 404 | |||
| 405 | ### Secure Mode vs Insecure Mode | ||
| 406 | |||
| 407 | | Feature | Insecure (`SECURE=0`) | Secure (`SECURE=1`) | | ||
| 408 | |---------|----------------------|---------------------| | ||
| 409 | | Protocol | HTTP | HTTPS | | ||
| 410 | | TLS | None | Self-signed CA + server cert | | ||
| 411 | | Auth | Optional | Required (htpasswd) | | ||
| 412 | | Target config | `insecure-registries` | CA cert distribution | | ||
| 413 | | Skopeo args | `--dest-tls-verify=false` | `--dest-cert-dir` | | ||
| 414 | |||
| 174 | ## vdkr Registry Usage | 415 | ## vdkr Registry Usage |
| 175 | 416 | ||
| 176 | ### Pull Behavior with Registry Fallback | 417 | ### Pull Behavior with Registry Fallback |
| @@ -233,9 +474,11 @@ This installs: | |||
| 233 | |------|-------------| | 474 | |------|-------------| |
| 234 | | `container-registry-index.bb` | Generates helper script with baked-in paths | | 475 | | `container-registry-index.bb` | Generates helper script with baked-in paths | |
| 235 | | `container-registry-populate.bb` | Alternative bitbake-driven push | | 476 | | `container-registry-populate.bb` | Alternative bitbake-driven push | |
| 477 | | `container-registry-ca.bb` | Target package for CA certificate (secure mode) | | ||
| 236 | | `container-oci-registry-config.bb` | OCI tools config (Podman/Skopeo/Buildah/CRI-O) | | 478 | | `container-oci-registry-config.bb` | OCI tools config (Podman/Skopeo/Buildah/CRI-O) | |
| 237 | | `docker-registry-config.bb` | Docker daemon config | | 479 | | `docker-registry-config.bb` | Docker daemon config | |
| 238 | | `files/container-registry-dev.yml` | Development registry config | | 480 | | `files/container-registry-dev.yml` | Development registry config (HTTP) | |
| 481 | | `files/container-registry-secure.yml` | Secure registry config template (HTTPS+auth) | | ||
| 239 | 482 | ||
| 240 | ## Storage | 483 | ## Storage |
| 241 | 484 | ||
diff --git a/tests/conftest.py b/tests/conftest.py index a0b8fd0b..d21f237c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py | |||
| @@ -245,6 +245,12 @@ def pytest_addoption(parser): | |||
| 245 | default=False, | 245 | default=False, |
| 246 | help="Skip registry tests that require network access to docker.io", | 246 | help="Skip registry tests that require network access to docker.io", |
| 247 | ) | 247 | ) |
| 248 | parser.addoption( | ||
| 249 | "--secure-registry", | ||
| 250 | action="store_true", | ||
| 251 | default=False, | ||
| 252 | help="Run secure registry tests (requires openssl, htpasswd)", | ||
| 253 | ) | ||
| 248 | 254 | ||
| 249 | 255 | ||
| 250 | def _cleanup_stale_test_state(): | 256 | def _cleanup_stale_test_state(): |
| @@ -577,6 +583,25 @@ def pytest_configure(config): | |||
| 577 | config.addinivalue_line( | 583 | config.addinivalue_line( |
| 578 | "markers", "network: marks tests that require network access" | 584 | "markers", "network: marks tests that require network access" |
| 579 | ) | 585 | ) |
| 586 | config.addinivalue_line( | ||
| 587 | "markers", "secure: marks tests that require secure registry mode (TLS/auth)" | ||
| 588 | ) | ||
| 589 | |||
| 590 | |||
| 591 | @pytest.fixture | ||
| 592 | def skip_secure(request): | ||
| 593 | """Skip if secure registry tests not enabled. | ||
| 594 | |||
| 595 | Use with tests that require secure registry infrastructure: | ||
| 596 | - openssl for certificate generation | ||
| 597 | - htpasswd for authentication setup | ||
| 598 | - CONTAINER_REGISTRY_SECURE=1 baked into script | ||
| 599 | |||
| 600 | Enable with: pytest --secure-registry | ||
| 601 | """ | ||
| 602 | if not request.config.getoption("--secure-registry"): | ||
| 603 | pytest.skip("Secure registry tests not enabled (use --secure-registry)") | ||
| 604 | return False | ||
| 580 | 605 | ||
| 581 | 606 | ||
| 582 | # ============================================================================ | 607 | # ============================================================================ |
diff --git a/tests/test_container_registry_script.py b/tests/test_container_registry_script.py index 444c5d3b..3089e870 100644 --- a/tests/test_container_registry_script.py +++ b/tests/test_container_registry_script.py | |||
| @@ -661,3 +661,929 @@ class TestRegistryIntegration: | |||
| 661 | finally: | 661 | finally: |
| 662 | # Always stop | 662 | # Always stop |
| 663 | registry.stop() | 663 | registry.stop() |
| 664 | |||
| 665 | |||
| 666 | class TestRegistryAuthentication: | ||
| 667 | """Test registry authentication features. | ||
| 668 | |||
| 669 | Tests for the authentication modes: | ||
| 670 | - none (default) | ||
| 671 | - home (~/.docker/config.json) | ||
| 672 | - authfile (explicit Docker config path) | ||
| 673 | - credsfile (simple key=value credentials file) | ||
| 674 | - env (environment variables, script only) | ||
| 675 | """ | ||
| 676 | |||
| 677 | def test_help_shows_auth_options(self, registry): | ||
| 678 | """Test that help shows authentication options.""" | ||
| 679 | result = registry.help() | ||
| 680 | assert "--use-home-auth" in result.stdout or "--authfile" in result.stdout | ||
| 681 | assert "--credsfile" in result.stdout | ||
| 682 | assert "--auth-mode" in result.stdout | ||
| 683 | assert "authentication" in result.stdout.lower() | ||
| 684 | |||
| 685 | def test_help_shows_import_auth_options(self, registry): | ||
| 686 | """Test that help shows import source authentication options.""" | ||
| 687 | result = registry.help() | ||
| 688 | assert "--src-authfile" in result.stdout or "--src-creds" in result.stdout | ||
| 689 | |||
| 690 | def test_push_with_unknown_auth_mode_fails(self, registry_session): | ||
| 691 | """Test that unknown auth mode fails.""" | ||
| 692 | registry_session.ensure_running() | ||
| 693 | result = registry_session.push_with_args("--auth-mode", "invalid") | ||
| 694 | assert result.returncode != 0 | ||
| 695 | assert "unknown" in result.stdout.lower() or "error" in result.stdout.lower() | ||
| 696 | |||
| 697 | def test_push_with_none_auth_mode(self, registry_session): | ||
| 698 | """Test that none auth mode works (default).""" | ||
| 699 | registry_session.ensure_running() | ||
| 700 | result = registry_session.push_with_args("--auth-mode", "none") | ||
| 701 | # Should work (no auth required for local registry) | ||
| 702 | assert result.returncode == 0 | ||
| 703 | |||
| 704 | def test_push_with_home_auth_no_config(self, registry_session, tmp_path, monkeypatch): | ||
| 705 | """Test that home auth mode fails when config doesn't exist.""" | ||
| 706 | registry_session.ensure_running() | ||
| 707 | # Point HOME to temp dir without .docker/config.json | ||
| 708 | monkeypatch.setenv("HOME", str(tmp_path)) | ||
| 709 | result = registry_session.push_with_args("--use-home-auth") | ||
| 710 | assert result.returncode != 0 | ||
| 711 | assert "not found" in result.stdout.lower() or "error" in result.stdout.lower() | ||
| 712 | |||
| 713 | def test_push_with_authfile_nonexistent(self, registry_session, tmp_path): | ||
| 714 | """Test that authfile mode fails when file doesn't exist.""" | ||
| 715 | registry_session.ensure_running() | ||
| 716 | nonexistent = tmp_path / "nonexistent-auth.json" | ||
| 717 | result = registry_session.push_with_args("--authfile", str(nonexistent)) | ||
| 718 | assert result.returncode != 0 | ||
| 719 | assert "not found" in result.stdout.lower() or "error" in result.stdout.lower() | ||
| 720 | |||
| 721 | def test_push_with_credsfile_nonexistent(self, registry_session, tmp_path): | ||
| 722 | """Test that credsfile mode fails when file doesn't exist.""" | ||
| 723 | registry_session.ensure_running() | ||
| 724 | nonexistent = tmp_path / "nonexistent-creds" | ||
| 725 | result = registry_session.push_with_args("--credsfile", str(nonexistent)) | ||
| 726 | assert result.returncode != 0 | ||
| 727 | assert "not found" in result.stdout.lower() or "error" in result.stdout.lower() | ||
| 728 | |||
| 729 | def test_push_with_credsfile_incomplete(self, registry_session, tmp_path): | ||
| 730 | """Test that credsfile mode fails when file is incomplete.""" | ||
| 731 | registry_session.ensure_running() | ||
| 732 | # Create credentials file with only username (missing password or token) | ||
| 733 | creds_file = tmp_path / "incomplete-creds" | ||
| 734 | creds_file.write_text("CONTAINER_REGISTRY_USER=testuser\n") | ||
| 735 | result = registry_session.push_with_args("--credsfile", str(creds_file)) | ||
| 736 | assert result.returncode != 0 | ||
| 737 | assert "must contain" in result.stdout.lower() or "error" in result.stdout.lower() | ||
| 738 | |||
| 739 | def test_push_with_valid_credsfile_user_password(self, registry_session, tmp_path): | ||
| 740 | """Test push with valid credsfile (username/password). | ||
| 741 | |||
| 742 | Note: This test uses fake credentials but validates the parsing works. | ||
| 743 | The push may fail auth against the registry but shows credentials are parsed. | ||
| 744 | """ | ||
| 745 | registry_session.ensure_running() | ||
| 746 | creds_file = tmp_path / "test-creds" | ||
| 747 | creds_file.write_text( | ||
| 748 | "CONTAINER_REGISTRY_USER=testuser\n" | ||
| 749 | "CONTAINER_REGISTRY_PASSWORD=testpass\n" | ||
| 750 | ) | ||
| 751 | result = registry_session.push_with_args("--credsfile", str(creds_file)) | ||
| 752 | # Should parse the file successfully and attempt push | ||
| 753 | # May succeed or fail auth, but shouldn't fail on parsing | ||
| 754 | output = result.stdout.lower() | ||
| 755 | # Should not contain parsing errors | ||
| 756 | assert "must contain" not in output | ||
| 757 | assert "credentials file not found" not in output | ||
| 758 | |||
| 759 | def test_push_with_valid_credsfile_token(self, registry_session, tmp_path): | ||
| 760 | """Test push with valid credsfile (token). | ||
| 761 | |||
| 762 | Token takes precedence over username/password. | ||
| 763 | """ | ||
| 764 | registry_session.ensure_running() | ||
| 765 | creds_file = tmp_path / "test-token-creds" | ||
| 766 | creds_file.write_text( | ||
| 767 | "# Comment line\n" | ||
| 768 | "CONTAINER_REGISTRY_TOKEN=test-token-123\n" | ||
| 769 | "\n" | ||
| 770 | "# Extra whitespace and comments are ignored\n" | ||
| 771 | ) | ||
| 772 | result = registry_session.push_with_args("--credsfile", str(creds_file)) | ||
| 773 | # Should parse the file successfully | ||
| 774 | output = result.stdout.lower() | ||
| 775 | assert "must contain" not in output | ||
| 776 | assert "credentials file not found" not in output | ||
| 777 | |||
| 778 | def test_push_with_credsfile_quoted_values(self, registry_session, tmp_path): | ||
| 779 | """Test push with credsfile containing quoted values.""" | ||
| 780 | registry_session.ensure_running() | ||
| 781 | creds_file = tmp_path / "quoted-creds" | ||
| 782 | creds_file.write_text( | ||
| 783 | 'CONTAINER_REGISTRY_USER="quoted-user"\n' | ||
| 784 | "CONTAINER_REGISTRY_PASSWORD='quoted-pass'\n" | ||
| 785 | ) | ||
| 786 | result = registry_session.push_with_args("--credsfile", str(creds_file)) | ||
| 787 | # Should parse quoted values correctly | ||
| 788 | output = result.stdout.lower() | ||
| 789 | assert "must contain" not in output | ||
| 790 | |||
| 791 | def test_push_with_env_auth_mode_no_creds(self, registry_session, monkeypatch): | ||
| 792 | """Test that env auth mode fails without env vars set.""" | ||
| 793 | registry_session.ensure_running() | ||
| 794 | # Clear any existing auth env vars | ||
| 795 | monkeypatch.delenv("CONTAINER_REGISTRY_TOKEN", raising=False) | ||
| 796 | monkeypatch.delenv("CONTAINER_REGISTRY_USER", raising=False) | ||
| 797 | monkeypatch.delenv("CONTAINER_REGISTRY_PASSWORD", raising=False) | ||
| 798 | |||
| 799 | result = registry_session.push_with_args("--auth-mode", "env") | ||
| 800 | assert result.returncode != 0 | ||
| 801 | assert "requires" in result.stdout.lower() or "error" in result.stdout.lower() | ||
| 802 | |||
| 803 | def test_push_with_env_auth_mode_token(self, registry_session, monkeypatch): | ||
| 804 | """Test env auth mode with token environment variable.""" | ||
| 805 | registry_session.ensure_running() | ||
| 806 | monkeypatch.setenv("CONTAINER_REGISTRY_TOKEN", "test-env-token") | ||
| 807 | |||
| 808 | result = registry_session.push_with_args("--auth-mode", "env") | ||
| 809 | # Should not fail on missing credentials | ||
| 810 | output = result.stdout.lower() | ||
| 811 | assert "requires" not in output or "requires" in output and "token or user" not in output | ||
| 812 | |||
| 813 | def test_push_with_direct_creds_option(self, registry_session): | ||
| 814 | """Test push with --creds option.""" | ||
| 815 | registry_session.ensure_running() | ||
| 816 | result = registry_session.push_with_args("--creds", "user:pass") | ||
| 817 | # Should attempt push with credentials | ||
| 818 | # May fail auth but shouldn't fail on parsing | ||
| 819 | output = result.stdout.lower() | ||
| 820 | assert "creds value missing" not in output | ||
| 821 | |||
| 822 | def test_push_with_direct_token_option(self, registry_session): | ||
| 823 | """Test push with --token option.""" | ||
| 824 | registry_session.ensure_running() | ||
| 825 | result = registry_session.push_with_args("--token", "test-direct-token") | ||
| 826 | # Should attempt push with token | ||
| 827 | output = result.stdout.lower() | ||
| 828 | assert "token value missing" not in output | ||
| 829 | |||
| 830 | def test_import_with_src_authfile_nonexistent(self, registry_session, tmp_path): | ||
| 831 | """Test import with nonexistent source authfile.""" | ||
| 832 | registry_session.ensure_running() | ||
| 833 | nonexistent = tmp_path / "nonexistent-src-auth.json" | ||
| 834 | result = registry_session.run( | ||
| 835 | "import", "docker.io/library/alpine:latest", | ||
| 836 | "--src-authfile", str(nonexistent), | ||
| 837 | check=False | ||
| 838 | ) | ||
| 839 | # Skopeo should fail when auth file doesn't exist | ||
| 840 | # Just verify we pass the option through correctly | ||
| 841 | assert result.returncode != 0 or "error" in result.stdout.lower() | ||
| 842 | |||
| 843 | def test_import_with_src_credsfile(self, registry_session, tmp_path): | ||
| 844 | """Test import with source credentials file.""" | ||
| 845 | registry_session.ensure_running() | ||
| 846 | creds_file = tmp_path / "src-creds" | ||
| 847 | creds_file.write_text( | ||
| 848 | "CONTAINER_REGISTRY_USER=testuser\n" | ||
| 849 | "CONTAINER_REGISTRY_PASSWORD=testpass\n" | ||
| 850 | ) | ||
| 851 | result = registry_session.run( | ||
| 852 | "import", "docker.io/library/alpine:latest", | ||
| 853 | "--src-credsfile", str(creds_file), | ||
| 854 | check=False, timeout=30 | ||
| 855 | ) | ||
| 856 | # Should parse credentials and attempt import | ||
| 857 | output = result.stdout.lower() | ||
| 858 | assert "must contain" not in output | ||
| 859 | assert "credentials file not found" not in output | ||
| 860 | |||
| 861 | def test_import_with_src_creds_direct(self, registry_session): | ||
| 862 | """Test import with direct source credentials.""" | ||
| 863 | registry_session.ensure_running() | ||
| 864 | result = registry_session.run( | ||
| 865 | "import", "docker.io/library/alpine:latest", | ||
| 866 | "--src-creds", "user:pass", | ||
| 867 | check=False, timeout=30 | ||
| 868 | ) | ||
| 869 | # Should attempt import with credentials | ||
| 870 | # Parsing should succeed even if auth fails | ||
| 871 | pass # Just verify no crash | ||
| 872 | |||
| 873 | |||
| 874 | class TestSecureRegistry: | ||
| 875 | """Test secure registry mode with TLS and authentication. | ||
| 876 | |||
| 877 | These tests verify the secure registry infrastructure: | ||
| 878 | - PKI generation (CA cert, server cert with SAN) | ||
| 879 | - htpasswd authentication setup | ||
| 880 | - HTTPS endpoints | ||
| 881 | - Auto-credential usage for push | ||
| 882 | |||
| 883 | Prerequisites: | ||
| 884 | - openssl and htpasswd must be installed on the system | ||
| 885 | - CONTAINER_REGISTRY_SECURE=1 environment variable | ||
| 886 | """ | ||
| 887 | |||
| 888 | @pytest.fixture | ||
| 889 | def secure_env(self, tmp_path, monkeypatch): | ||
| 890 | """Set up environment for secure registry testing.""" | ||
| 891 | storage = tmp_path / "container-registry" | ||
| 892 | storage.mkdir() | ||
| 893 | monkeypatch.setenv("CONTAINER_REGISTRY_STORAGE", str(storage)) | ||
| 894 | monkeypatch.setenv("CONTAINER_REGISTRY_SECURE", "1") | ||
| 895 | return storage | ||
| 896 | |||
| 897 | def test_help_shows_secure_mode(self, registry): | ||
| 898 | """Test that help documents secure mode.""" | ||
| 899 | result = registry.help() | ||
| 900 | output = result.stdout.lower() | ||
| 901 | # Help should mention secure mode or TLS | ||
| 902 | assert "secure" in output or "tls" in output or "https" in output | ||
| 903 | |||
| 904 | def test_start_generates_pki(self, registry, secure_env): | ||
| 905 | """Test that start generates PKI in secure mode.""" | ||
| 906 | # Note: Requires openssl and htpasswd installed | ||
| 907 | result = registry.start(timeout=60) | ||
| 908 | |||
| 909 | # Check for missing dependencies | ||
| 910 | output = result.stdout.lower() | ||
| 911 | if "openssl" in output and "not found" in output: | ||
| 912 | pytest.skip("openssl not available") | ||
| 913 | if "htpasswd" in output and "not found" in output: | ||
| 914 | pytest.skip("htpasswd not available") | ||
| 915 | |||
| 916 | # Skip if secure mode not enabled (baked script may not have it) | ||
| 917 | if "secure" not in output and "https" not in output: | ||
| 918 | pytest.skip("Secure mode not enabled in baked script") | ||
| 919 | |||
| 920 | pki_dir = secure_env / "pki" | ||
| 921 | auth_dir = secure_env / "auth" | ||
| 922 | |||
| 923 | # Check PKI files generated | ||
| 924 | assert (pki_dir / "ca.crt").exists(), "CA cert not generated" | ||
| 925 | assert (pki_dir / "ca.key").exists(), "CA key not generated" | ||
| 926 | assert (pki_dir / "server.crt").exists(), "Server cert not generated" | ||
| 927 | assert (pki_dir / "server.key").exists(), "Server key not generated" | ||
| 928 | |||
| 929 | # Check auth files generated | ||
| 930 | assert (auth_dir / "htpasswd").exists(), "htpasswd not generated" | ||
| 931 | assert (auth_dir / "password").exists(), "password file not generated" | ||
| 932 | |||
| 933 | # Verify permissions on sensitive files | ||
| 934 | ca_key_mode = oct((pki_dir / "ca.key").stat().st_mode)[-3:] | ||
| 935 | server_key_mode = oct((pki_dir / "server.key").stat().st_mode)[-3:] | ||
| 936 | password_mode = oct((auth_dir / "password").stat().st_mode)[-3:] | ||
| 937 | assert ca_key_mode == "600", f"CA key has wrong permissions: {ca_key_mode}" | ||
| 938 | assert server_key_mode == "600", f"Server key has wrong permissions: {server_key_mode}" | ||
| 939 | assert password_mode == "600", f"Password file has wrong permissions: {password_mode}" | ||
| 940 | |||
| 941 | def test_start_shows_https_url(self, registry, secure_env): | ||
| 942 | """Test that start shows https:// URL in secure mode.""" | ||
| 943 | result = registry.start(timeout=60) | ||
| 944 | output = result.stdout.lower() | ||
| 945 | |||
| 946 | # Skip if secure mode not enabled | ||
| 947 | if "secure" not in output and "https" not in output: | ||
| 948 | pytest.skip("Secure mode not enabled in baked script") | ||
| 949 | |||
| 950 | assert "https://" in result.stdout | ||
| 951 | |||
| 952 | def test_pki_not_regenerated(self, registry, secure_env): | ||
| 953 | """Test that existing PKI is not overwritten.""" | ||
| 954 | # First start generates PKI | ||
| 955 | result = registry.start(timeout=60) | ||
| 956 | output = result.stdout.lower() | ||
| 957 | |||
| 958 | # Skip if secure mode not enabled | ||
| 959 | if "secure" not in output and "https" not in output: | ||
| 960 | pytest.skip("Secure mode not enabled in baked script") | ||
| 961 | |||
| 962 | pki_dir = secure_env / "pki" | ||
| 963 | if not (pki_dir / "ca.crt").exists(): | ||
| 964 | pytest.skip("PKI not generated (missing dependencies?)") | ||
| 965 | |||
| 966 | ca_crt_mtime = (pki_dir / "ca.crt").stat().st_mtime | ||
| 967 | |||
| 968 | # Stop and restart | ||
| 969 | registry.stop() | ||
| 970 | time.sleep(1) | ||
| 971 | registry.start(timeout=60) | ||
| 972 | |||
| 973 | # CA cert should not be regenerated | ||
| 974 | new_mtime = (pki_dir / "ca.crt").stat().st_mtime | ||
| 975 | assert new_mtime == ca_crt_mtime, "CA cert was regenerated" | ||
| 976 | |||
| 977 | def test_custom_username(self, registry, secure_env, monkeypatch): | ||
| 978 | """Test custom username configuration.""" | ||
| 979 | monkeypatch.setenv("CONTAINER_REGISTRY_USERNAME", "customuser") | ||
| 980 | result = registry.start(timeout=60) | ||
| 981 | output = result.stdout.lower() | ||
| 982 | |||
| 983 | # Skip if secure mode not enabled | ||
| 984 | if "secure" not in output and "https" not in output: | ||
| 985 | pytest.skip("Secure mode not enabled in baked script") | ||
| 986 | |||
| 987 | htpasswd = secure_env / "auth" / "htpasswd" | ||
| 988 | if not htpasswd.exists(): | ||
| 989 | pytest.skip("htpasswd not generated (missing dependencies?)") | ||
| 990 | |||
| 991 | content = htpasswd.read_text() | ||
| 992 | assert content.startswith("customuser:"), f"htpasswd should start with customuser: but got {content[:20]}" | ||
| 993 | |||
| 994 | def test_custom_password(self, registry, secure_env, monkeypatch): | ||
| 995 | """Test custom password configuration.""" | ||
| 996 | monkeypatch.setenv("CONTAINER_REGISTRY_PASSWORD", "custompass123") | ||
| 997 | result = registry.start(timeout=60) | ||
| 998 | output = result.stdout.lower() | ||
| 999 | |||
| 1000 | # Skip if secure mode not enabled | ||
| 1001 | if "secure" not in output and "https" not in output: | ||
| 1002 | pytest.skip("Secure mode not enabled in baked script") | ||
| 1003 | |||
| 1004 | password_file = secure_env / "auth" / "password" | ||
| 1005 | if not password_file.exists(): | ||
| 1006 | pytest.skip("password file not generated (missing dependencies?)") | ||
| 1007 | |||
| 1008 | password = password_file.read_text().strip() | ||
| 1009 | assert password == "custompass123", f"Password should be custompass123 but got {password}" | ||
| 1010 | |||
| 1011 | def test_server_cert_san(self, registry, secure_env): | ||
| 1012 | """Test that server cert includes expected SAN entries.""" | ||
| 1013 | result = registry.start(timeout=60) | ||
| 1014 | output = result.stdout.lower() | ||
| 1015 | |||
| 1016 | # Skip if secure mode not enabled | ||
| 1017 | if "secure" not in output and "https" not in output: | ||
| 1018 | pytest.skip("Secure mode not enabled in baked script") | ||
| 1019 | |||
| 1020 | server_crt = secure_env / "pki" / "server.crt" | ||
| 1021 | if not server_crt.exists(): | ||
| 1022 | pytest.skip("Server cert not generated (missing dependencies?)") | ||
| 1023 | |||
| 1024 | cert_result = subprocess.run( | ||
| 1025 | ["openssl", "x509", "-in", str(server_crt), "-noout", "-text"], | ||
| 1026 | capture_output=True, text=True | ||
| 1027 | ) | ||
| 1028 | |||
| 1029 | if cert_result.returncode != 0: | ||
| 1030 | pytest.skip("openssl not available") | ||
| 1031 | |||
| 1032 | cert_text = cert_result.stdout | ||
| 1033 | |||
| 1034 | # Check SAN entries | ||
| 1035 | assert "DNS:localhost" in cert_text, "Server cert missing DNS:localhost SAN" | ||
| 1036 | assert "IP Address:127.0.0.1" in cert_text, "Server cert missing IP:127.0.0.1 SAN" | ||
| 1037 | assert "IP Address:10.0.2.2" in cert_text, "Server cert missing IP:10.0.2.2 SAN" | ||
| 1038 | |||
| 1039 | def test_push_uses_credentials(self, registry, secure_env): | ||
| 1040 | """Test that push auto-uses generated credentials in secure mode.""" | ||
| 1041 | result = registry.start(timeout=60) | ||
| 1042 | output = result.stdout.lower() | ||
| 1043 | |||
| 1044 | # Skip if secure mode not enabled | ||
| 1045 | if "secure" not in output and "https" not in output: | ||
| 1046 | pytest.skip("Secure mode not enabled in baked script") | ||
| 1047 | |||
| 1048 | # Attempt push - it should use auto-generated credentials | ||
| 1049 | push_result = registry.run("push", check=False, timeout=120) | ||
| 1050 | push_output = push_result.stdout.lower() | ||
| 1051 | |||
| 1052 | # Should not show "unauthorized" errors (credentials should work) | ||
| 1053 | # May show "no images" which is fine | ||
| 1054 | assert "unauthorized" not in push_output, "Push failed with unauthorized - credentials not used" | ||
| 1055 | |||
| 1056 | @pytest.mark.network | ||
| 1057 | @pytest.mark.slow | ||
| 1058 | def test_secure_import(self, registry, secure_env, skip_network): | ||
| 1059 | """Test importing with TLS verification.""" | ||
| 1060 | if skip_network: | ||
| 1061 | pytest.skip("Network tests disabled (--skip-registry-network)") | ||
| 1062 | |||
| 1063 | result = registry.start(timeout=60) | ||
| 1064 | output = result.stdout.lower() | ||
| 1065 | |||
| 1066 | # Skip if secure mode not enabled | ||
| 1067 | if "secure" not in output and "https" not in output: | ||
| 1068 | pytest.skip("Secure mode not enabled in baked script") | ||
| 1069 | |||
| 1070 | # Import should work with our CA cert | ||
| 1071 | import_result = registry.import_image( | ||
| 1072 | "docker.io/library/alpine:latest", | ||
| 1073 | timeout=300 | ||
| 1074 | ) | ||
| 1075 | assert import_result.returncode == 0, f"Import failed: {import_result.stdout}" | ||
| 1076 | |||
| 1077 | def test_tls_curl_verification(self, registry, secure_env): | ||
| 1078 | """Test that curl can verify the registry TLS.""" | ||
| 1079 | result = registry.start(timeout=60) | ||
| 1080 | output = result.stdout.lower() | ||
| 1081 | |||
| 1082 | # Skip if secure mode not enabled | ||
| 1083 | if "secure" not in output and "https" not in output: | ||
| 1084 | pytest.skip("Secure mode not enabled in baked script") | ||
| 1085 | |||
| 1086 | ca_cert = secure_env / "pki" / "ca.crt" | ||
| 1087 | password_file = secure_env / "auth" / "password" | ||
| 1088 | |||
| 1089 | if not ca_cert.exists() or not password_file.exists(): | ||
| 1090 | pytest.skip("PKI/auth not generated (missing dependencies?)") | ||
| 1091 | |||
| 1092 | password = password_file.read_text().strip() | ||
| 1093 | |||
| 1094 | curl_result = subprocess.run([ | ||
| 1095 | "curl", "-s", "--cacert", str(ca_cert), | ||
| 1096 | "-u", f"yocto:{password}", | ||
| 1097 | "https://localhost:5000/v2/_catalog" | ||
| 1098 | ], capture_output=True, text=True, timeout=30) | ||
| 1099 | |||
| 1100 | # Should get valid JSON response | ||
| 1101 | assert curl_result.returncode == 0 or "repositories" in curl_result.stdout, \ | ||
| 1102 | f"Curl TLS verification failed: {curl_result.stderr}" | ||
| 1103 | |||
| 1104 | def test_status_shows_secure_mode(self, registry, secure_env): | ||
| 1105 | """Test that status indicates secure mode.""" | ||
| 1106 | result = registry.start(timeout=60) | ||
| 1107 | start_output = result.stdout.lower() | ||
| 1108 | |||
| 1109 | # Skip if secure mode not enabled | ||
| 1110 | if "secure" not in start_output and "https" not in start_output: | ||
| 1111 | pytest.skip("Secure mode not enabled in baked script") | ||
| 1112 | |||
| 1113 | status_result = registry.status() | ||
| 1114 | status_output = status_result.stdout.lower() | ||
| 1115 | |||
| 1116 | # Status should indicate secure/TLS mode | ||
| 1117 | assert "https" in status_output or "secure" in status_output or "tls" in status_output, \ | ||
| 1118 | "Status should indicate secure mode" | ||
| 1119 | |||
| 1120 | |||
| 1121 | class TestSecureRegistryTLSOnly: | ||
| 1122 | """Test TLS-only mode (SECURE=1, AUTH=0). | ||
| 1123 | |||
| 1124 | When AUTH is not enabled, the registry should: | ||
| 1125 | - Use HTTPS (TLS) for connections | ||
| 1126 | - NOT require authentication | ||
| 1127 | - Allow anonymous pull/push | ||
| 1128 | |||
| 1129 | These tests work with an already-running secure registry. | ||
| 1130 | """ | ||
| 1131 | |||
| 1132 | @pytest.fixture | ||
| 1133 | def registry_storage(self, registry): | ||
| 1134 | """Get registry storage path from the script.""" | ||
| 1135 | result = registry.run("info", check=False) | ||
| 1136 | # Parse storage path from info output | ||
| 1137 | for line in result.stdout.splitlines(): | ||
| 1138 | if "Storage:" in line: | ||
| 1139 | return Path(line.split("Storage:")[-1].strip()) | ||
| 1140 | # Fall back to common locations | ||
| 1141 | for candidate in [ | ||
| 1142 | Path(os.environ.get("TOPDIR", "")) / "container-registry", | ||
| 1143 | Path.cwd() / "container-registry", | ||
| 1144 | Path.cwd().parent / "container-registry", | ||
| 1145 | ]: | ||
| 1146 | if candidate.exists(): | ||
| 1147 | return candidate | ||
| 1148 | pytest.skip("Cannot determine registry storage path") | ||
| 1149 | |||
| 1150 | def _ensure_secure_registry(self, registry): | ||
| 1151 | """Ensure a secure registry is running, starting one if needed.""" | ||
| 1152 | status = registry.status() | ||
| 1153 | if status.returncode == 0: | ||
| 1154 | # Already running - verify it's secure | ||
| 1155 | if "secure" not in status.stdout.lower() and "tls" not in status.stdout.lower(): | ||
| 1156 | pytest.skip("Registry running but not in secure mode") | ||
| 1157 | return | ||
| 1158 | |||
| 1159 | # Not running - try to start it | ||
| 1160 | result = registry.start(timeout=60) | ||
| 1161 | if result.returncode != 0: | ||
| 1162 | pytest.skip(f"Could not start registry: {result.stderr}") | ||
| 1163 | import time | ||
| 1164 | time.sleep(2) | ||
| 1165 | |||
| 1166 | status = registry.status() | ||
| 1167 | if status.returncode != 0: | ||
| 1168 | pytest.skip("Registry failed to start") | ||
| 1169 | if "secure" not in status.stdout.lower() and "tls" not in status.stdout.lower(): | ||
| 1170 | pytest.skip("Registry started but not in secure mode") | ||
| 1171 | |||
| 1172 | def test_status_shows_tls_only(self, registry): | ||
| 1173 | """Test that status shows TLS-only mode (not TLS+auth).""" | ||
| 1174 | self._ensure_secure_registry(registry) | ||
| 1175 | status = registry.status() | ||
| 1176 | status_output = status.stdout.lower() | ||
| 1177 | |||
| 1178 | # Should show secure mode | ||
| 1179 | assert "secure" in status_output or "tls" in status_output, \ | ||
| 1180 | "Status should indicate secure mode" | ||
| 1181 | # In TLS-only mode, should say "tls only" not "tls + auth" | ||
| 1182 | if "tls only" in status_output: | ||
| 1183 | assert "tls + auth" not in status_output | ||
| 1184 | |||
| 1185 | def test_curl_without_auth(self, registry, registry_storage): | ||
| 1186 | """Test that curl can access registry without credentials.""" | ||
| 1187 | self._ensure_secure_registry(registry) | ||
| 1188 | |||
| 1189 | ca_cert = registry_storage / "pki" / "ca.crt" | ||
| 1190 | if not ca_cert.exists(): | ||
| 1191 | pytest.skip("CA cert not found") | ||
| 1192 | |||
| 1193 | # Access WITHOUT credentials should work in TLS-only mode | ||
| 1194 | curl_result = subprocess.run([ | ||
| 1195 | "curl", "-s", "--cacert", str(ca_cert), | ||
| 1196 | "https://localhost:5000/v2/_catalog" | ||
| 1197 | ], capture_output=True, text=True, timeout=30) | ||
| 1198 | |||
| 1199 | assert "repositories" in curl_result.stdout, \ | ||
| 1200 | f"TLS-only registry should not require auth: {curl_result.stderr}" | ||
| 1201 | |||
| 1202 | def test_config_has_no_auth_section(self, registry, registry_storage): | ||
| 1203 | """Test that generated registry config has no active auth section.""" | ||
| 1204 | self._ensure_secure_registry(registry) | ||
| 1205 | |||
| 1206 | config_file = registry_storage / "registry-config.yml" | ||
| 1207 | if not config_file.exists(): | ||
| 1208 | pytest.skip("Config file not found") | ||
| 1209 | |||
| 1210 | config_content = config_file.read_text() | ||
| 1211 | # Check that the actual YAML auth: key is not present as a config directive | ||
| 1212 | # (comments mentioning htpasswd are fine, only the active auth block matters) | ||
| 1213 | non_comment_lines = [ | ||
| 1214 | line for line in config_content.splitlines() | ||
| 1215 | if line.strip() and not line.strip().startswith('#') | ||
| 1216 | ] | ||
| 1217 | active_config = '\n'.join(non_comment_lines) | ||
| 1218 | assert "auth:" not in active_config, \ | ||
| 1219 | "TLS-only config should not have an active auth: section" | ||
| 1220 | assert "tls:" in config_content, \ | ||
| 1221 | "TLS-only config should contain TLS section" | ||
| 1222 | |||
| 1223 | def test_pki_exists(self, registry, registry_storage): | ||
| 1224 | """Test that PKI infrastructure exists.""" | ||
| 1225 | self._ensure_secure_registry(registry) | ||
| 1226 | |||
| 1227 | pki_dir = registry_storage / "pki" | ||
| 1228 | assert (pki_dir / "ca.crt").exists(), "CA cert should exist" | ||
| 1229 | assert (pki_dir / "server.crt").exists(), "Server cert should exist" | ||
| 1230 | assert (pki_dir / "server.key").exists(), "Server key should exist" | ||
| 1231 | |||
| 1232 | def test_no_htpasswd_generated(self, registry, registry_storage): | ||
| 1233 | """Test that TLS-only mode does not require htpasswd authentication. | ||
| 1234 | |||
| 1235 | Note: A stale auth/htpasswd may exist from previous runs when auth | ||
| 1236 | was enabled. We verify the functional behavior: the registry config | ||
| 1237 | has no auth section and anonymous access works (tested separately | ||
| 1238 | in test_curl_without_auth). Here we check the config file. | ||
| 1239 | """ | ||
| 1240 | self._ensure_secure_registry(registry) | ||
| 1241 | |||
| 1242 | config_file = registry_storage / "registry-config.yml" | ||
| 1243 | if not config_file.exists(): | ||
| 1244 | pytest.skip("Config file not found") | ||
| 1245 | |||
| 1246 | config_content = config_file.read_text() | ||
| 1247 | non_comment_lines = [ | ||
| 1248 | line for line in config_content.splitlines() | ||
| 1249 | if line.strip() and not line.strip().startswith('#') | ||
| 1250 | ] | ||
| 1251 | active_config = '\n'.join(non_comment_lines) | ||
| 1252 | assert "auth:" not in active_config, \ | ||
| 1253 | "TLS-only config should not reference htpasswd authentication" | ||
| 1254 | |||
| 1255 | def test_info_shows_no_auth(self, registry): | ||
| 1256 | """Test that info command reflects TLS-only (no auth section).""" | ||
| 1257 | self._ensure_secure_registry(registry) | ||
| 1258 | |||
| 1259 | info_result = registry.run("info", check=False) | ||
| 1260 | info_output = info_result.stdout | ||
| 1261 | |||
| 1262 | assert "PKI" in info_result.stdout or "pki" in info_result.stdout.lower(), \ | ||
| 1263 | "Info should show PKI directory" | ||
| 1264 | assert "Password file" not in info_result.stdout, \ | ||
| 1265 | "Info should not show password file in TLS-only mode" | ||
| 1266 | |||
| 1267 | |||
| 1268 | class TestSecureRegistryWithAuth: | ||
| 1269 | """Test TLS+auth mode (SECURE=1, AUTH=1). | ||
| 1270 | |||
| 1271 | Uses an isolated registry instance on port 5001 with its own | ||
| 1272 | storage directory, so it never touches the user's running registry. | ||
| 1273 | """ | ||
| 1274 | |||
| 1275 | TEST_PORT = "5001" | ||
| 1276 | |||
| 1277 | @pytest.fixture | ||
| 1278 | def auth_registry(self, registry, tmp_path, monkeypatch): | ||
| 1279 | """Start an isolated auth-enabled registry on a different port.""" | ||
| 1280 | storage = tmp_path / "auth-registry" | ||
| 1281 | storage.mkdir() | ||
| 1282 | |||
| 1283 | monkeypatch.setenv("CONTAINER_REGISTRY_STORAGE", str(storage)) | ||
| 1284 | monkeypatch.setenv("CONTAINER_REGISTRY_SECURE", "1") | ||
| 1285 | monkeypatch.setenv("CONTAINER_REGISTRY_AUTH", "1") | ||
| 1286 | monkeypatch.setenv("CONTAINER_REGISTRY_URL", f"localhost:{self.TEST_PORT}") | ||
| 1287 | |||
| 1288 | start_result = registry.start(timeout=60) | ||
| 1289 | if start_result.returncode != 0: | ||
| 1290 | pytest.skip(f"Could not start auth registry: {start_result.stderr}") | ||
| 1291 | |||
| 1292 | output = start_result.stdout.lower() | ||
| 1293 | if "secure" not in output and "https" not in output: | ||
| 1294 | registry.stop() | ||
| 1295 | pytest.skip("Script does not support secure mode") | ||
| 1296 | if "htpasswd" in output and "not found" in output: | ||
| 1297 | registry.stop() | ||
| 1298 | pytest.skip("htpasswd not available") | ||
| 1299 | |||
| 1300 | import time | ||
| 1301 | time.sleep(2) | ||
| 1302 | yield storage | ||
| 1303 | |||
| 1304 | registry.stop() | ||
| 1305 | |||
| 1306 | def test_curl_requires_auth(self, registry, auth_registry): | ||
| 1307 | """Test that curl without credentials is rejected.""" | ||
| 1308 | ca_cert = auth_registry / "pki" / "ca.crt" | ||
| 1309 | if not ca_cert.exists(): | ||
| 1310 | pytest.skip("CA cert not found") | ||
| 1311 | |||
| 1312 | curl_result = subprocess.run([ | ||
| 1313 | "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", | ||
| 1314 | "--cacert", str(ca_cert), | ||
| 1315 | f"https://localhost:{self.TEST_PORT}/v2/_catalog" | ||
| 1316 | ], capture_output=True, text=True, timeout=30) | ||
| 1317 | |||
| 1318 | assert "401" in curl_result.stdout, \ | ||
| 1319 | f"Auth-enabled registry should reject unauthenticated requests, got: {curl_result.stdout}" | ||
| 1320 | |||
| 1321 | def test_curl_with_auth_succeeds(self, registry, auth_registry): | ||
| 1322 | """Test that curl with credentials succeeds.""" | ||
| 1323 | ca_cert = auth_registry / "pki" / "ca.crt" | ||
| 1324 | password_file = auth_registry / "auth" / "password" | ||
| 1325 | if not ca_cert.exists() or not password_file.exists(): | ||
| 1326 | pytest.skip("PKI/auth not generated") | ||
| 1327 | |||
| 1328 | password = password_file.read_text().strip() | ||
| 1329 | |||
| 1330 | curl_result = subprocess.run([ | ||
| 1331 | "curl", "-s", "--cacert", str(ca_cert), | ||
| 1332 | "-u", f"yocto:{password}", | ||
| 1333 | f"https://localhost:{self.TEST_PORT}/v2/_catalog" | ||
| 1334 | ], capture_output=True, text=True, timeout=30) | ||
| 1335 | |||
| 1336 | assert "repositories" in curl_result.stdout, \ | ||
| 1337 | f"Authenticated request should succeed: {curl_result.stderr}" | ||
| 1338 | |||
| 1339 | def test_config_has_auth_section(self, registry, auth_registry): | ||
| 1340 | """Test that generated registry config includes auth section.""" | ||
| 1341 | config_file = auth_registry / "registry-config.yml" | ||
| 1342 | if not config_file.exists(): | ||
| 1343 | pytest.skip("Config file not found") | ||
| 1344 | |||
| 1345 | config_content = config_file.read_text() | ||
| 1346 | assert "htpasswd" in config_content, \ | ||
| 1347 | "Auth-enabled config should contain htpasswd section" | ||
| 1348 | assert "tls:" in config_content, \ | ||
| 1349 | "Auth-enabled config should also contain TLS section" | ||
| 1350 | |||
| 1351 | def test_htpasswd_generated(self, registry, auth_registry): | ||
| 1352 | """Test that htpasswd file is generated in auth mode.""" | ||
| 1353 | auth_dir = auth_registry / "auth" | ||
| 1354 | assert (auth_dir / "htpasswd").exists(), "htpasswd not generated in auth mode" | ||
| 1355 | assert (auth_dir / "password").exists(), "password file not generated" | ||
| 1356 | |||
| 1357 | |||
| 1358 | class TestDockerRegistryConfig: | ||
| 1359 | """Test docker-registry-config.bb behavior. | ||
| 1360 | |||
| 1361 | Verifies the bitbake recipe logic for generating Docker daemon | ||
| 1362 | configuration on target images, specifically: | ||
| 1363 | - localhost→10.0.2.2 translation for QEMU targets | ||
| 1364 | - CA cert installation path matches registry host | ||
| 1365 | """ | ||
| 1366 | |||
| 1367 | def test_bbclass_has_auth_variable(self): | ||
| 1368 | """Test that container-registry.bbclass defines CONTAINER_REGISTRY_AUTH.""" | ||
| 1369 | bbclass = Path("/opt/bruce/poky/meta-virtualization/classes/container-registry.bbclass") | ||
| 1370 | if not bbclass.exists(): | ||
| 1371 | pytest.skip("container-registry.bbclass not found") | ||
| 1372 | |||
| 1373 | content = bbclass.read_text() | ||
| 1374 | assert 'CONTAINER_REGISTRY_AUTH' in content, \ | ||
| 1375 | "bbclass should define CONTAINER_REGISTRY_AUTH variable" | ||
| 1376 | assert 'CONTAINER_REGISTRY_AUTH ?= "0"' in content, \ | ||
| 1377 | "CONTAINER_REGISTRY_AUTH should default to 0" | ||
| 1378 | |||
| 1379 | def test_bbclass_validates_auth_requires_secure(self): | ||
| 1380 | """Test that bbclass warns when AUTH=1 without SECURE=1.""" | ||
| 1381 | bbclass = Path("/opt/bruce/poky/meta-virtualization/classes/container-registry.bbclass") | ||
| 1382 | if not bbclass.exists(): | ||
| 1383 | pytest.skip("container-registry.bbclass not found") | ||
| 1384 | |||
| 1385 | content = bbclass.read_text() | ||
| 1386 | assert "auth and not secure" in content or "AUTH" in content, \ | ||
| 1387 | "bbclass should validate that AUTH requires SECURE" | ||
| 1388 | |||
| 1389 | def test_docker_registry_config_translates_localhost(self): | ||
| 1390 | """Test that docker-registry-config.bb translates localhost to 10.0.2.2.""" | ||
| 1391 | recipe = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1392 | "container-registry/docker-registry-config.bb") | ||
| 1393 | if not recipe.exists(): | ||
| 1394 | pytest.skip("docker-registry-config.bb not found") | ||
| 1395 | |||
| 1396 | content = recipe.read_text() | ||
| 1397 | assert "10.0.2.2" in content, \ | ||
| 1398 | "Recipe should translate localhost to 10.0.2.2 for QEMU" | ||
| 1399 | assert "replace" in content.lower() and "localhost" in content, \ | ||
| 1400 | "Recipe should replace localhost with 10.0.2.2" | ||
| 1401 | |||
| 1402 | def test_docker_registry_config_translates_127(self): | ||
| 1403 | """Test that docker-registry-config.bb also translates 127.0.0.1.""" | ||
| 1404 | recipe = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1405 | "container-registry/docker-registry-config.bb") | ||
| 1406 | if not recipe.exists(): | ||
| 1407 | pytest.skip("docker-registry-config.bb not found") | ||
| 1408 | |||
| 1409 | content = recipe.read_text() | ||
| 1410 | assert "127.0.0.1" in content, \ | ||
| 1411 | "Recipe should also translate 127.0.0.1 to 10.0.2.2" | ||
| 1412 | |||
| 1413 | |||
| 1414 | class TestContainerCrossInstallSecure: | ||
| 1415 | """Test container-cross-install.bbclass secure registry integration. | ||
| 1416 | |||
| 1417 | Verifies that the cross-install class automatically adds the | ||
| 1418 | docker-registry-config package when CONTAINER_REGISTRY_SECURE=1. | ||
| 1419 | """ | ||
| 1420 | |||
| 1421 | def test_cross_install_auto_adds_registry_config(self): | ||
| 1422 | """Test that cross-install adds docker-registry-config when SECURE=1.""" | ||
| 1423 | bbclass = Path("/opt/bruce/poky/meta-virtualization/classes/" | ||
| 1424 | "container-cross-install.bbclass") | ||
| 1425 | if not bbclass.exists(): | ||
| 1426 | pytest.skip("container-cross-install.bbclass not found") | ||
| 1427 | |||
| 1428 | content = bbclass.read_text() | ||
| 1429 | assert "CONTAINER_REGISTRY_SECURE" in content, \ | ||
| 1430 | "Cross-install should check CONTAINER_REGISTRY_SECURE" | ||
| 1431 | assert "docker-registry-config" in content, \ | ||
| 1432 | "Cross-install should add docker-registry-config in secure mode" | ||
| 1433 | |||
| 1434 | def test_cross_install_supports_podman_config(self): | ||
| 1435 | """Test that cross-install adds container-oci-registry-config for podman.""" | ||
| 1436 | bbclass = Path("/opt/bruce/poky/meta-virtualization/classes/" | ||
| 1437 | "container-cross-install.bbclass") | ||
| 1438 | if not bbclass.exists(): | ||
| 1439 | pytest.skip("container-cross-install.bbclass not found") | ||
| 1440 | |||
| 1441 | content = bbclass.read_text() | ||
| 1442 | assert "container-oci-registry-config" in content, \ | ||
| 1443 | "Cross-install should support podman registry config" | ||
| 1444 | |||
| 1445 | def test_cross_install_checks_container_engine(self): | ||
| 1446 | """Test that cross-install selects config package based on engine.""" | ||
| 1447 | bbclass = Path("/opt/bruce/poky/meta-virtualization/classes/" | ||
| 1448 | "container-cross-install.bbclass") | ||
| 1449 | if not bbclass.exists(): | ||
| 1450 | pytest.skip("container-cross-install.bbclass not found") | ||
| 1451 | |||
| 1452 | content = bbclass.read_text() | ||
| 1453 | assert "VIRTUAL-RUNTIME_container_engine" in content, \ | ||
| 1454 | "Cross-install should check container engine to select config package" | ||
| 1455 | |||
| 1456 | |||
| 1457 | class TestVcontainerSecureRegistry: | ||
| 1458 | """Test vcontainer shell script secure registry support. | ||
| 1459 | |||
| 1460 | Verifies the host-side scripts handle CA certificates correctly: | ||
| 1461 | - Auto-detection of bundled CA cert | ||
| 1462 | - CA cert transport via virtio-9p (not kernel cmdline) | ||
| 1463 | - Daemon mode sets _9p=1 for share mounting | ||
| 1464 | """ | ||
| 1465 | |||
| 1466 | def test_vcontainer_common_auto_detects_ca_cert(self): | ||
| 1467 | """Test that vcontainer-common.sh auto-detects bundled CA cert.""" | ||
| 1468 | script = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1469 | "vcontainer/files/vcontainer-common.sh") | ||
| 1470 | if not script.exists(): | ||
| 1471 | pytest.skip("vcontainer-common.sh not found") | ||
| 1472 | |||
| 1473 | content = script.read_text() | ||
| 1474 | assert "registry/ca.crt" in content, \ | ||
| 1475 | "Should check for bundled CA cert at registry/ca.crt" | ||
| 1476 | assert "BUNDLED_CA_CERT" in content, \ | ||
| 1477 | "Should define BUNDLED_CA_CERT variable" | ||
| 1478 | assert 'SECURE_REGISTRY="true"' in content, \ | ||
| 1479 | "Should auto-enable SECURE_REGISTRY when CA cert found" | ||
| 1480 | |||
| 1481 | def test_vrunner_passes_ca_via_virtio9p(self): | ||
| 1482 | """Test that vrunner.sh passes CA cert via virtio-9p, not cmdline.""" | ||
| 1483 | script = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1484 | "vcontainer/files/vrunner.sh") | ||
| 1485 | if not script.exists(): | ||
| 1486 | pytest.skip("vrunner.sh not found") | ||
| 1487 | |||
| 1488 | content = script.read_text() | ||
| 1489 | |||
| 1490 | # Should copy CA cert to shared folder in daemon mode | ||
| 1491 | assert "DAEMON_SHARE_DIR" in content and "ca.crt" in content, \ | ||
| 1492 | "Should copy CA cert to DAEMON_SHARE_DIR in daemon mode" | ||
| 1493 | |||
| 1494 | # Should NOT base64 encode CA cert for cmdline | ||
| 1495 | assert "base64" not in content.split("CA_CERT")[0].split("CA_CERT")[-1] \ | ||
| 1496 | or "registry_pass" in content, \ | ||
| 1497 | "Should not base64 encode CA cert for kernel cmdline" | ||
| 1498 | |||
| 1499 | def test_vrunner_daemon_sets_9p(self): | ||
| 1500 | """Test that daemon mode sets _9p=1 in kernel cmdline.""" | ||
| 1501 | script = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1502 | "vcontainer/files/vrunner.sh") | ||
| 1503 | if not script.exists(): | ||
| 1504 | pytest.skip("vrunner.sh not found") | ||
| 1505 | |||
| 1506 | content = script.read_text() | ||
| 1507 | |||
| 1508 | # Find the daemon mode block and check for _9p=1 | ||
| 1509 | # The daemon block should contain both virtfs and _9p=1 | ||
| 1510 | lines = content.split('\n') | ||
| 1511 | in_daemon_block = False | ||
| 1512 | daemon_has_virtfs = False | ||
| 1513 | daemon_has_9p = False | ||
| 1514 | for line in lines: | ||
| 1515 | if 'DAEMON_MODE" = "start"' in line or "DAEMON_MODE\" = \"start\"" in line: | ||
| 1516 | in_daemon_block = True | ||
| 1517 | if in_daemon_block: | ||
| 1518 | if "virtfs" in line and "DAEMON_SHARE_DIR" in line: | ||
| 1519 | daemon_has_virtfs = True | ||
| 1520 | if "_9p=1" in line: | ||
| 1521 | daemon_has_9p = True | ||
| 1522 | # Detect end of the if block (next top-level statement) | ||
| 1523 | if line.startswith("fi") and in_daemon_block and daemon_has_virtfs: | ||
| 1524 | break | ||
| 1525 | |||
| 1526 | assert daemon_has_virtfs, "Daemon mode should set up virtio-9p share" | ||
| 1527 | assert daemon_has_9p, "Daemon mode should set _9p=1 in kernel cmdline" | ||
| 1528 | |||
| 1529 | def test_vrunner_nondaemon_ca_cert_virtio9p(self): | ||
| 1530 | """Test that non-daemon mode creates virtio-9p share for CA cert.""" | ||
| 1531 | script = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1532 | "vcontainer/files/vrunner.sh") | ||
| 1533 | if not script.exists(): | ||
| 1534 | pytest.skip("vrunner.sh not found") | ||
| 1535 | |||
| 1536 | content = script.read_text() | ||
| 1537 | assert "CA_SHARE_DIR" in content, \ | ||
| 1538 | "Non-daemon mode should create CA_SHARE_DIR for virtio-9p" | ||
| 1539 | assert "cashare" in content, \ | ||
| 1540 | "Should add virtio-9p device for CA cert sharing" | ||
| 1541 | |||
| 1542 | def test_vdkr_init_reads_ca_from_share(self): | ||
| 1543 | """Test that vdkr-init.sh reads CA cert from /mnt/share/ca.crt.""" | ||
| 1544 | script = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1545 | "vcontainer/files/vdkr-init.sh") | ||
| 1546 | if not script.exists(): | ||
| 1547 | pytest.skip("vdkr-init.sh not found") | ||
| 1548 | |||
| 1549 | content = script.read_text() | ||
| 1550 | assert "/mnt/share/ca.crt" in content, \ | ||
| 1551 | "Should check for CA cert at /mnt/share/ca.crt" | ||
| 1552 | |||
| 1553 | def test_vdkr_init_no_base64_ca_decode(self): | ||
| 1554 | """Test that vdkr-init.sh no longer decodes base64 CA from cmdline.""" | ||
| 1555 | script = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1556 | "vcontainer/files/vdkr-init.sh") | ||
| 1557 | if not script.exists(): | ||
| 1558 | pytest.skip("vdkr-init.sh not found") | ||
| 1559 | |||
| 1560 | content = script.read_text() | ||
| 1561 | # Should NOT have docker_registry_ca=<base64> pattern in cmdline parsing | ||
| 1562 | assert "docker_registry_ca=" not in content or "docker_registry_ca=1" in content, \ | ||
| 1563 | "Should not parse base64 CA cert from kernel cmdline" | ||
| 1564 | |||
| 1565 | def test_vdkr_init_copies_ca_from_share(self): | ||
| 1566 | """Test that vdkr-init.sh copies CA cert from shared folder.""" | ||
| 1567 | script = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1568 | "vcontainer/files/vdkr-init.sh") | ||
| 1569 | if not script.exists(): | ||
| 1570 | pytest.skip("vdkr-init.sh not found") | ||
| 1571 | |||
| 1572 | content = script.read_text() | ||
| 1573 | # Should copy from DOCKER_REGISTRY_CA (which is /mnt/share/ca.crt) | ||
| 1574 | assert "cp" in content and "DOCKER_REGISTRY_CA" in content, \ | ||
| 1575 | "Should copy CA cert from shared folder path" | ||
| 1576 | assert "/etc/docker/certs.d/" in content, \ | ||
| 1577 | "Should install CA cert to Docker certs.d directory" | ||
| 1578 | |||
| 1579 | def test_vcontainer_tarball_tracks_scripts(self): | ||
| 1580 | """Test that vcontainer-tarball.bb tracks script files via SRC_URI.""" | ||
| 1581 | recipe = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | ||
| 1582 | "vcontainer/vcontainer-tarball.bb") | ||
| 1583 | if not recipe.exists(): | ||
| 1584 | pytest.skip("vcontainer-tarball.bb not found") | ||
| 1585 | |||
| 1586 | content = recipe.read_text() | ||
| 1587 | assert "SRC_URI" in content, "Should have SRC_URI for file tracking" | ||
| 1588 | assert "vrunner.sh" in content, "Should track vrunner.sh" | ||
| 1589 | assert "vcontainer-common.sh" in content, "Should track vcontainer-common.sh" | ||
